From 7cc61b314857894964e1e8a5205480777f39d716 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Thu, 20 Aug 2026 12:33:18 +0000 Subject: [PATCH 01/11] Support tool search Signed-off-by: haoshan98 --- .../src/executor/accumulator.rs | 13 +- .../src/executor/compaction.rs | 58 +- .../src/executor/engine.rs | 120 +- .../agentic-server-core/src/executor/error.rs | 20 + .../src/executor/function_sse.rs | 984 ++++++++++- .../src/executor/gateway.rs | 6 +- .../agentic-server-core/src/executor/mod.rs | 3 +- .../src/executor/modes/conversation.rs | 20 +- .../src/executor/modes/response.rs | 15 +- .../src/executor/persist.rs | 4 +- .../src/executor/prepare.rs | 28 + .../src/executor/rehydrate.rs | 200 ++- .../src/executor/request.rs | 130 +- .../src/executor/upstream.rs | 53 +- .../src/storage/conversation.rs | 21 +- .../src/storage/models/item.rs | 11 + .../src/storage/models/response.rs | 17 + .../src/storage/types/conversation.rs | 6 + .../src/storage/types/item.rs | 45 +- .../src/storage/types/response.rs | 38 +- crates/agentic-server-core/src/tool/codex.rs | 186 +- .../src/tool/mcp/handler.rs | 42 +- .../agentic-server-core/src/tool/mcp/pool.rs | 30 +- crates/agentic-server-core/src/tool/mod.rs | 6 +- crates/agentic-server-core/src/tool/names.rs | 153 ++ .../agentic-server-core/src/tool/normalize.rs | 18 + .../agentic-server-core/src/tool/registry.rs | 556 +++++- crates/agentic-server-core/src/tool/search.rs | 1497 +++++++++++++++++ .../agentic-server-core/src/types/io/input.rs | 187 +- .../agentic-server-core/src/types/io/mod.rs | 6 +- .../src/types/io/output.rs | 96 +- crates/agentic-server-core/src/types/mod.rs | 15 +- .../src/types/request_response.rs | 472 +++++- .../src/types/tools/mod.rs | 3 +- .../src/types/tools/params.rs | 128 +- .../agentic-server-core/tests/support/mod.rs | 71 + .../src/handler/http/responses.rs | 1 + .../src/handler/websocket/responses.rs | 4 +- 38 files changed, 5006 insertions(+), 257 deletions(-) create mode 100644 crates/agentic-server-core/src/executor/prepare.rs create mode 100644 crates/agentic-server-core/src/tool/names.rs create mode 100644 crates/agentic-server-core/src/tool/search.rs diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 70b2d7c1..50cc5e0c 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -152,8 +152,13 @@ impl ResponseAccumulator { /// # Errors /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { - let mut json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; + let json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; + Self::from_value(json, conversation_id) + } + /// Rehydrate a parsed non-streaming response without parsing the body a + /// second time after raw protocol validation. + pub(super) fn from_value(mut json: serde_json::Value, conversation_id: Option<&str>) -> ExecutorResult { let response_id = json["id"] .as_str() .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))? @@ -278,11 +283,15 @@ impl ResponseAccumulator { line: &str, translator: &mut FunctionSseTranslator, ) -> ExecutorResult> { - let Some(frame) = self.process_sse_line(line) else { + let Some(frame) = normalize_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.validate_before_accumulation(&frame, call)?; + self.capture_terminal_details_if_needed(&frame); + self.process_event(&frame); + let call = call_key.and_then(|(item_id, output_index)| self.accumulated_function_call(item_id, output_index)); translator.translate(frame, call).map(Some) } diff --git a/crates/agentic-server-core/src/executor/compaction.rs b/crates/agentic-server-core/src/executor/compaction.rs index 3863fcf4..dfc69317 100644 --- a/crates/agentic-server-core/src/executor/compaction.rs +++ b/crates/agentic-server-core/src/executor/compaction.rs @@ -1,4 +1,5 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::prepare::prepare_tool_search; use crate::executor::rehydrate::rehydrate_conversation; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::executor::upstream::fetch_blocking_payload; @@ -87,6 +88,8 @@ 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.has_content(), + InputItem::ToolSearchCall(call) => !call.call_id.trim().is_empty() || !call.arguments.is_empty(), + InputItem::ToolSearchOutput(output) => !output.call_id.trim().is_empty() || !output.tools.is_empty(), InputItem::CustomToolCall(call) => !call.name.trim().is_empty() || !call.input.trim().is_empty(), InputItem::CustomToolCallOutput(output) => output.output.has_content(), InputItem::Reasoning(reasoning) => { @@ -195,12 +198,15 @@ pub(crate) async fn compact_items( let ctx = RequestContext { original_request, enriched_request, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: uuid7_str("resp_"), conversation_id: None, conversation_version: None, }; - let response = fetch_blocking_payload(&ctx, exec_ctx, auth).await?; + let response = fetch_blocking_payload(&ctx, exec_ctx, auth, &crate::tool::ToolRegistry::default()).await?; let summary = completed_summary_text(&response)?; Ok(( @@ -233,7 +239,7 @@ pub(crate) async fn maybe_compact_context( let Some(threshold) = threshold else { return Ok(None); }; - let estimated_tokens = estimate_input_tokens(&ctx.enriched_request.input); + let estimated_tokens = estimate_input_tokens(&ctx.inference_request().input); if estimated_tokens <= threshold { return Ok(None); } @@ -245,8 +251,15 @@ pub(crate) async fn maybe_compact_context( ); let model = ctx.enriched_request.model.clone(); let instructions = ctx.enriched_request.instructions.clone(); - let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new())); + let has_private_request = ctx.tool_search_private_request.is_some(); + let input = std::mem::replace( + &mut ctx.inference_request_mut().input, + ResponsesInput::Items(Vec::new()), + ); let (compacted, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; + if has_private_request { + ctx.inference_request_mut().input = ResponsesInput::Items(compacted.clone()); + } ctx.enriched_request.input = ResponsesInput::Items(compacted.clone()); ctx.new_input_items = compacted; Ok(Some(usage)) @@ -276,9 +289,13 @@ pub async fn compact_response( ); payload.previous_response_id = request.previous_response_id; let mut ctx = rehydrate_conversation(payload, exec_ctx).await?; + prepare_tool_search(&mut ctx)?; let model = ctx.enriched_request.model.clone(); let instructions = ctx.enriched_request.instructions.clone(); - let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new())); + let input = std::mem::replace( + &mut ctx.inference_request_mut().input, + ResponsesInput::Items(Vec::new()), + ); let (output, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; let response_id = ctx.response_id.clone(); @@ -545,6 +562,38 @@ mod tests { server.abort(); } + #[tokio::test] + async fn compaction_prepares_tool_search_before_summarization() { + let (exec_ctx, server) = mock_execution_context(ResponseStore::disabled()).await; + let request = serde_json::from_value(serde_json::json!({ + "model": "test-model", + "input": [{ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + }] + })) + .expect("valid compact request"); + + let compacted = compact_response(request, &exec_ctx, None) + .await + .expect("compaction prepares and summarizes public search history"); + + assert!(matches!(compacted.output.last(), Some(InputItem::Compaction(_)))); + assert!( + compacted + .output + .iter() + .all(|item| !matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))) + ); + server.abort(); + } + #[tokio::test] async fn compaction_persists_a_reusable_response_checkpoint() { let pool = create_pool_with_schema(Some("sqlite::memory:")) @@ -582,6 +631,7 @@ mod tests { model: "test-model".to_owned(), previous_response_id: None, effective_tools: None, + tool_search_loaded_tools: None, effective_tool_choice: crate::ToolChoice::Auto, effective_instructions: None, }, diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index 7256cedb..870168a8 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -24,12 +24,13 @@ use crate::events::EventFrame; use crate::executor::error::ExecutorResult; use crate::executor::inference::DONE_MARKER; use crate::executor::persist::persist_if_needed; -use crate::executor::rehydrate::rehydrate_conversation; +use crate::executor::rehydrate::rehydrate_for_execution; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload}; use crate::tool::{ToolRegistry, mcp}; use crate::types::io::{OutputItem, ResponseUsage, ToolChoice}; use crate::types::request_response::{IncompleteDetails, RequestPayload, ResponsePayload}; +use crate::utils::common::utcnow_str; pub use crate::executor::inference::BoxStream; @@ -100,11 +101,7 @@ async fn run_until_gateway_tools_complete( stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult<(ResponsePayload, RequestContext)> { - let mut executors = exec_ctx.gateway_executors.request_scoped(); - let registry: ToolRegistry = match ctx.enriched_request.tools.as_mut() { - Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?, - None => ToolRegistry::default(), - }; + let registry = build_request_tool_registry(&mut ctx, exec_ctx).await?; let mut combined_output: Vec = registry .mcp_list_tools_items() .iter() @@ -130,22 +127,20 @@ async fn run_until_gateway_tools_complete( .await?; (stream_payload.payload, stream_payload.deferred_events) } else { - (fetch_blocking_payload(&ctx, exec_ctx, auth).await?, Vec::new()) + ( + fetch_blocking_payload(&ctx, exec_ctx, auth, ®istry).await?, + Vec::new(), + ) }; - registry.restore_final_payload_output(&mut payload.output); + registry.restore_final_payload_output(&mut payload.output)?; accumulate_usage(&mut combined_usage, payload.usage.take()); let current_output = std::mem::take(&mut payload.output); - for item in ¤t_output { - if let OutputItem::CustomToolCall(call) = item { - debug!( - response_id = %ctx.response_id, - call_id = %call.call_id, - name = %call.name, - input_bytes = call.input.len(), - "custom tool call requires client execution" - ); - } + if matches!(payload.status.as_str(), "error" | "failed" | "incomplete") { + combined_output.extend(current_output); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx); + return Ok((payload, ctx)); } + log_custom_tool_calls(¤t_output, &ctx.response_id); let has_client_owned = has_client_owned_calls(¤t_output, ®istry); let gateway_results = execute_and_emit_round_output_calls( ¤t_output, @@ -197,8 +192,8 @@ async fn run_until_gateway_tools_complete( } // Gateway tools ran and rounds remain; feed outputs back and loop. LoopDecision::Continue => { - ctx.enriched_request.tool_choice = Some(ToolChoice::Auto); - append_output_items_to_input(&mut ctx.enriched_request.input, ¤t_output); + ctx.inference_request_mut().tool_choice = Some(ToolChoice::Auto); + append_output_items_to_input(&mut ctx.inference_request_mut().input, ¤t_output); append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry); append_tool_outputs( &mut ctx, @@ -211,6 +206,35 @@ async fn run_until_gateway_tools_complete( unreachable!("the final round returns Done, RequiresClientAction, or Incomplete"); } +fn log_custom_tool_calls(output: &[OutputItem], response_id: &str) { + for item in output { + if let OutputItem::CustomToolCall(call) = item { + debug!( + response_id, + call_id = %call.call_id, + name = %call.name, + input_bytes = call.input.len(), + "custom tool call requires client execution" + ); + } + } +} + +async fn build_request_tool_registry( + ctx: &mut RequestContext, + exec_ctx: &ExecutionContext, +) -> ExecutorResult { + let mut executors = exec_ctx.gateway_executors.request_scoped(); + let mut registry = match ctx.inference_request_mut().tools.as_mut() { + Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?, + None => ToolRegistry::default(), + }; + if let Some(state) = &ctx.tool_search_state { + registry.classify_tool_search(state)?; + } + Ok(registry) +} + async fn execute_and_emit_round_output_calls( output_items: &[OutputItem], registry: &ToolRegistry, @@ -356,6 +380,7 @@ async fn run_blocking( fn run_stream(ctx: RequestContext, exec_ctx: Arc, auth: Option) -> BoxStream { Box::pin(stream! { + let failure_context = StreamFailureContext::from(&ctx); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let exec_ctx_for_run = Arc::clone(&exec_ctx); let event_tx_for_run = event_tx.clone(); @@ -390,7 +415,15 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc, auth: Option while let Ok(event) = event_rx.try_recv() { yield consume_stream_event(event, &mut next_sequence_number); } - yield stream_accumulator.executor_error_chunk(&e); + if e.is_invalid_upstream_tool_search() { + let payload = failure_context.failed_payload(&e); + match stream_accumulator.terminal_response_chunk(&payload) { + Ok(chunk) => yield chunk, + Err(serialize_error) => yield stream_accumulator.executor_error_chunk(&serialize_error), + } + } else { + yield stream_accumulator.executor_error_chunk(&e); + } yield DONE_MARKER.to_string(); } Ok((Ok((payload, ctx)), mut stream_accumulator)) => { @@ -422,6 +455,49 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc, auth: Option }) } +struct StreamFailureContext { + response_id: String, + conversation_id: Option, + model: String, + previous_response_id: Option, + instructions: Option, +} + +impl From<&RequestContext> for StreamFailureContext { + fn from(ctx: &RequestContext) -> Self { + Self { + response_id: ctx.response_id.clone(), + conversation_id: ctx.conversation_id.clone(), + model: ctx.enriched_request.model.clone(), + previous_response_id: ctx.original_request.previous_response_id.clone(), + instructions: ctx.original_request.instructions.clone(), + } + } +} + +impl StreamFailureContext { + fn failed_payload(&self, error: &crate::executor::error::ExecutorError) -> ResponsePayload { + ResponsePayload { + id: self.response_id.clone(), + object: "response".to_owned(), + created_at: utcnow_str(), + model: self.model.clone(), + status: "failed".to_owned(), + output: Vec::new(), + usage: None, + incomplete_details: None, + error: Some(serde_json::json!({ + "message": error.error_message(), + "type": error.error_type(), + "code": error.error_code(), + })), + previous_response_id: self.previous_response_id.clone(), + conversation_id: self.conversation_id.clone(), + instructions: self.instructions.clone(), + } + } +} + fn consume_stream_event(event: StreamEvent, next_sequence_number: &mut u64) -> String { *next_sequence_number = event.sequence_number.saturating_add(1); event.content @@ -503,7 +579,7 @@ impl ExecuteRequest { tools = self.payload.tools.as_ref().map_or(0, Vec::len), "executor received responses request" ); - let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?; + let ctx = rehydrate_for_execution(self.payload, &self.exec_ctx).await?; if ctx.original_request.stream { Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth))) } else { diff --git a/crates/agentic-server-core/src/executor/error.rs b/crates/agentic-server-core/src/executor/error.rs index fde513e4..75f0e4dd 100644 --- a/crates/agentic-server-core/src/executor/error.rs +++ b/crates/agentic-server-core/src/executor/error.rs @@ -81,6 +81,18 @@ pub enum ExecutorError { } impl ExecutorError { + pub(crate) fn is_invalid_upstream_tool_search(&self) -> bool { + matches!( + self, + Self::Tool(ToolError::Execution(message)) + if matches!( + message.as_str(), + "upstream returned an invalid tool-search call" + | "upstream returned a call for a function that has not been loaded" + ) + ) + } + fn client_visible_error(&self) -> &Self { match self { Self::Persistence(source) if source.contains_conversation_locked() => source.client_visible_error(), @@ -209,6 +221,14 @@ mod tests { assert!(exec_err.to_string().contains("storage error")); } + #[test] + fn tool_search_configuration_errors_are_bad_requests() { + let error = ExecutorError::from(ToolError::Config("invalid tool_search request".to_owned())); + + assert_eq!(error.http_status(), StatusCode::BAD_REQUEST); + assert_eq!(error.error_type(), "invalid_request_error"); + } + #[test] fn test_executor_error_json_preserves_source() { use std::error::Error; diff --git a/crates/agentic-server-core/src/executor/function_sse.rs b/crates/agentic-server-core/src/executor/function_sse.rs index 2f654e44..76537d32 100644 --- a/crates/agentic-server-core/src/executor/function_sse.rs +++ b/crates/agentic-server-core/src/executor/function_sse.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::Value; @@ -6,16 +6,29 @@ 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; +use crate::tool::{ToolType, search}; +use crate::types::io::OutputItem; +use crate::utils::common::{serialize_to_string, serialize_to_value}; const MAX_PENDING_FUNCTION_BYTES: usize = 256 * 1024; +const MAX_PENDING_FUNCTION_CALLS: usize = 128; #[derive(Debug)] enum FunctionCallShape { PublicFunction, GatewayOwned, Custom(CustomCallState), + ToolSearch(ToolSearchCallState), +} + +#[derive(Debug)] +struct ToolSearchCallState { + upstream_item_id: String, + public_item_id: String, + call_id: String, + output_index: u32, + accounted_argument_bytes: usize, + arguments_done: bool, } #[derive(Debug)] @@ -51,21 +64,38 @@ pub(super) struct FunctionSseTranslator { pending_unnamed: HashMap, pending_bytes: usize, first_gateway_output_index: Option, + search_call_seen: bool, + tool_search_enabled: bool, + withheld_function_names: HashSet, + upstream_terminal_failure: bool, } impl FunctionSseTranslator { pub(super) fn new(tool_types: HashMap) -> Self { + let tool_search_enabled = tool_types.get("tool_search") == Some(&ToolType::ToolSearch); Self { tool_types, + tool_search_enabled, ..Self::default() } } + pub(super) fn with_withheld_function_names(mut self, names: &HashSet) -> Self { + self.withheld_function_names.clone_from(names); + self + } + pub(super) fn translate( &mut self, frame: EventFrame, call: Option>, ) -> ExecutorResult { + if matches!( + frame.event_type, + SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete + ) { + self.upstream_terminal_failure = true; + } let mut translated = match &frame.payload { EventPayload::OutputItemAdded { item_id, @@ -86,10 +116,11 @@ impl FunctionSseTranslator { } => self.translate_delta(item_id, *output_index, frame.clone(), call), EventPayload::FunctionCallArgsDone { item_id, + call_id, name, output_index, .. - } => self.finish_arguments(item_id, name, *output_index, frame.clone(), call), + } => self.finish_arguments(item_id, name, *output_index, call_id.as_deref(), frame.clone(), call), EventPayload::OutputItemDone { item_id, item_type: SSEItemType::FunctionCall, @@ -108,6 +139,197 @@ impl FunctionSseTranslator { Ok(translated) } + pub(super) fn finish(&self) -> ExecutorResult<()> { + if !self.upstream_terminal_failure + && (self + .active + .values() + .any(|shape| matches!(shape, FunctionCallShape::ToolSearch(_))) + || (self.tool_search_enabled && !self.pending_unnamed.is_empty())) + { + return Err(search::invalid_upstream_search_call().into()); + } + Ok(()) + } + + pub(super) fn unfinished_search_item_ids(&self) -> HashSet<&str> { + let mut item_ids = self + .active + .values() + .filter_map(|shape| match shape { + FunctionCallShape::ToolSearch(state) => Some(state.upstream_item_id.as_str()), + FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned | FunctionCallShape::Custom(_) => { + None + } + }) + .collect::>(); + if self.tool_search_enabled { + item_ids.extend(self.pending_unnamed.values().flat_map(|pending| { + pending.frames.iter().filter_map(|frame| match &frame.payload { + EventPayload::OutputItemAdded { item_id, .. } if !item_id.is_empty() => Some(item_id.as_str()), + _ => None, + }) + })); + } + item_ids + } + + pub(super) fn validate_before_accumulation( + &mut self, + frame: &EventFrame, + call: Option>, + ) -> ExecutorResult<()> { + self.validate_withheld_function_names(frame)?; + match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + output_index, + name: None, + .. + } => self.validate_pending_frame_before_accumulation(*output_index, frame), + EventPayload::FunctionCallArgsDone { + arguments, + item_id, + name, + output_index, + call_id, + } if self.tool_search_enabled + && (name == "tool_search" + || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch(_)))) => + { + validate_wire_output_index(frame, *output_index)?; + if arguments.len() > MAX_PENDING_FUNCTION_BYTES { + return Err(search::invalid_upstream_search_call().into()); + } + if let Some(FunctionCallShape::ToolSearch(state)) = self.active.get(output_index) { + validate_active_tool_search_done_name(frame)?; + validate_stream_linkage(state, item_id, call_id.as_deref())?; + } + Ok(()) + } + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + output_index, + item, + .. + } if self.tool_search_enabled + && (item.get("name").and_then(Value::as_str) == Some("tool_search") + || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch(_)))) => + { + validate_wire_output_index(frame, *output_index)?; + let object = item.as_object().ok_or_else(search::invalid_upstream_search_call)?; + let arguments = object + .get("arguments") + .and_then(Value::as_str) + .ok_or_else(search::invalid_upstream_search_call)?; + if arguments.len() > MAX_PENDING_FUNCTION_BYTES { + return Err(search::invalid_upstream_search_call().into()); + } + let public = search::public_output_item_from_raw(object)?; + if let Some(FunctionCallShape::ToolSearch(state)) = self.active.get(output_index) { + let OutputItem::ToolSearchCall(public) = public else { + return Err(search::invalid_upstream_search_call().into()); + }; + if public.id != state.public_item_id || public.call_id != state.call_id { + return Err(search::invalid_upstream_search_call().into()); + } + } + Ok(()) + } + EventPayload::FunctionCallArgsDelta { + delta, + call_id, + item_id, + output_index, + } => { + let Some(shape) = self.active.get_mut(output_index) else { + return self.validate_pending_frame_before_accumulation(*output_index, frame); + }; + match shape { + FunctionCallShape::Custom(_) => { + let current = call.map_or(0, |call| call.arguments().len()); + ensure_function_call_size_for(current, delta.len()) + } + FunctionCallShape::ToolSearch(state) => { + validate_wire_output_index(frame, *output_index)?; + validate_stream_linkage(state, item_id, call_id.as_deref())?; + if state.accounted_argument_bytes.saturating_add(delta.len()) > MAX_PENDING_FUNCTION_BYTES { + return Err(search::invalid_upstream_search_call().into()); + } + state.accounted_argument_bytes = state.accounted_argument_bytes.saturating_add(delta.len()); + Ok(()) + } + FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned => Ok(()), + } + } + _ => Ok(()), + } + } + + fn validate_withheld_function_names(&self, frame: &EventFrame) -> ExecutorResult<()> { + let terminal_has_withheld_call = frame.event_type == SSEEventType::ResponseCompleted + && frame + .wire + .rest + .get("response") + .and_then(|response| response.get("output")) + .and_then(Value::as_array) + .is_some_and(|output| { + output.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call") + && item + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| self.withheld_function_names.contains(name)) + }) + }); + let lifecycle_name = match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + name: Some(name), + .. + } + | EventPayload::FunctionCallArgsDone { name, .. } => Some(name.as_str()), + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + item, + .. + } => item.get("name").and_then(Value::as_str), + _ => None, + }; + if terminal_has_withheld_call || lifecycle_name.is_some_and(|name| self.withheld_function_names.contains(name)) + { + return Err(search::invalid_upstream_withheld_function_call().into()); + } + Ok(()) + } + + fn validate_pending_frame_before_accumulation(&self, output_index: u32, frame: &EventFrame) -> ExecutorResult<()> { + if !self.pending_unnamed.contains_key(&output_index) && self.pending_unnamed.len() >= MAX_PENDING_FUNCTION_CALLS + { + return Err(self.pending_limit_error(format!( + "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_CALLS} pending calls" + ))); + } + 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(self.pending_limit_error(format!( + "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" + ))); + } + Ok(()) + } + + fn pending_limit_error(&self, message: String) -> ExecutorError { + if self.tool_search_enabled { + search::invalid_upstream_search_call().into() + } else { + ExecutorError::StreamError(message) + } + } + fn start_call( &mut self, item_id: &str, @@ -149,6 +371,42 @@ impl FunctionSseTranslator { self.active.insert(output_index, FunctionCallShape::GatewayOwned); Ok(FunctionSseTranslation::default()) } + ToolType::ToolSearch => { + if self.search_call_seen + || self.active.contains_key(&output_index) + || self.pending_unnamed.contains_key(&output_index) + { + return Err(search::invalid_upstream_search_call().into()); + } + let call = call.ok_or_else(search::invalid_upstream_search_call)?; + if call.item.namespace.is_some() { + return Err(search::invalid_upstream_search_call().into()); + } + let original = original.as_ref().ok_or_else(search::invalid_upstream_search_call)?; + validate_wire_output_index(original, output_index)?; + let public_item = search::public_added_item(item_id, &call.item.call_id)?; + let public_item_id = public_item["id"].as_str().unwrap_or_default().to_owned(); + self.search_call_seen = true; + self.active.insert( + output_index, + FunctionCallShape::ToolSearch(ToolSearchCallState { + upstream_item_id: item_id.to_owned(), + public_item_id, + call_id: call.item.call_id.clone(), + output_index, + accounted_argument_bytes: call.arguments().len(), + arguments_done: false, + }), + ); + Ok(FunctionSseTranslation { + frames: vec![tool_search_frame( + SSEEventType::OutputItemAdded, + output_index, + public_item, + )?], + defer_from_output_index: None, + }) + } ToolType::Function | ToolType::CodexNamespace => { self.active.insert(output_index, FunctionCallShape::PublicFunction); Ok(FunctionSseTranslation { @@ -161,7 +419,7 @@ impl FunctionSseTranslator { fn translate_delta( &mut self, - _item_id: &str, + item_id: &str, output_index: u32, original: EventFrame, call: Option>, @@ -182,6 +440,14 @@ impl FunctionSseTranslator { defer_from_output_index: None, }) } + Some(FunctionCallShape::ToolSearch(state)) => { + let event_call_id = match &original.payload { + EventPayload::FunctionCallArgsDelta { call_id, .. } => call_id.as_deref(), + _ => None, + }; + validate_stream_linkage(state, item_id, event_call_id)?; + Ok(FunctionSseTranslation::default()) + } None => self.buffer_unnamed(output_index, original), } } @@ -191,6 +457,7 @@ impl FunctionSseTranslator { item_id: &str, name: &str, output_index: u32, + event_call_id: Option<&str>, original: EventFrame, call: Option>, ) -> ExecutorResult { @@ -203,6 +470,20 @@ impl FunctionSseTranslator { translated.frames.extend(finish_custom_input(state, call.arguments())?); } } + Some(FunctionCallShape::ToolSearch(state)) => { + validate_active_tool_search_done_name(&original)?; + validate_stream_linkage(state, item_id, event_call_id)?; + let call = call.ok_or_else(search::invalid_upstream_search_call)?; + validate_search_call_state(state, &call, item_id)?; + let public = search::public_output_item(&call.item.id, &call.item.call_id, call.arguments())?; + let OutputItem::ToolSearchCall(public) = public else { + return Err(search::invalid_upstream_search_call().into()); + }; + if public.id != state.public_item_id || public.call_id != state.call_id { + return Err(search::invalid_upstream_search_call().into()); + } + state.arguments_done = true; + } } Ok(translated) } @@ -227,6 +508,33 @@ impl FunctionSseTranslator { translated.frames.push(custom_done_frame(&state, &call)?); } } + Some(FunctionCallShape::ToolSearch(state)) => { + let call = call.ok_or_else(search::invalid_upstream_search_call)?; + validate_search_call_state(&state, &call, item_id)?; + let object = original + .wire + .rest + .get("item") + .and_then(Value::as_object) + .ok_or_else(search::invalid_upstream_search_call)?; + let public = search::public_output_item_from_raw(object)?; + let OutputItem::ToolSearchCall(public_call) = &public else { + return Err(search::invalid_upstream_search_call().into()); + }; + if public_call.id != state.public_item_id + || public_call.call_id != state.call_id + || public_call.arguments != search_arguments(&call)? + || (!state.arguments_done && call.arguments().is_empty()) + { + return Err(search::invalid_upstream_search_call().into()); + } + let item = serialize_to_value(&public).map_err(ExecutorError::JsonError)?; + translated.frames.push(tool_search_frame( + SSEEventType::OutputItemDone, + state.output_index, + item, + )?); + } } Ok(translated) } @@ -243,7 +551,7 @@ impl FunctionSseTranslator { } let pending = self.take_pending(output_index); - let original_added = pending.iter().find(|frame| { + let added = pending.iter().filter(|frame| { matches!( frame.payload, EventPayload::OutputItemAdded { @@ -252,10 +560,28 @@ impl FunctionSseTranslator { } ) }); - let mut translated = self.start_call(item_id, name, output_index, original_added.cloned(), call)?; + let added = added.collect::>(); + if self.tool_type(name) == ToolType::ToolSearch && added.len() != 1 { + return Err(search::invalid_upstream_search_call().into()); + } + let original_added = added.first().copied(); + let start_item_id = original_added.and_then(|frame| match &frame.payload { + EventPayload::OutputItemAdded { item_id, .. } => Some(item_id.as_str()), + _ => None, + }); + let mut translated = self.start_call( + start_item_id.unwrap_or(item_id), + name, + output_index, + original_added.cloned(), + call, + )?; for frame in pending { - if let EventPayload::FunctionCallArgsDelta { output_index, .. } = &frame.payload { + if let EventPayload::FunctionCallArgsDelta { + item_id, output_index, .. + } = &frame.payload + { let delta = self.translate_delta(item_id, *output_index, frame.clone(), call)?; translated.frames.extend(delta.frames); } @@ -279,10 +605,16 @@ impl FunctionSseTranslator { .map_err(ExecutorError::JsonError)? .len(); if self.pending_bytes.saturating_add(bytes) > MAX_PENDING_FUNCTION_BYTES { - return Err(ExecutorError::StreamError(format!( + return Err(self.pending_limit_error(format!( "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" ))); } + if !self.pending_unnamed.contains_key(&output_index) && self.pending_unnamed.len() >= MAX_PENDING_FUNCTION_CALLS + { + return Err(self.pending_limit_error(format!( + "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_CALLS} pending calls" + ))); + } let pending = self .pending_unnamed .entry(output_index) @@ -305,6 +637,63 @@ impl FunctionSseTranslator { } } +fn validate_search_call_state( + state: &ToolSearchCallState, + call: &AccumulatedFunctionCall<'_>, + event_item_id: &str, +) -> ExecutorResult<()> { + ensure_function_call_size(call.arguments())?; + if call.output_index != state.output_index + || call.item.call_id != state.call_id + || call.item.id != state.upstream_item_id + || event_item_id != state.upstream_item_id + { + return Err(search::invalid_upstream_search_call().into()); + } + Ok(()) +} + +fn validate_stream_linkage(state: &ToolSearchCallState, item_id: &str, call_id: Option<&str>) -> ExecutorResult<()> { + if item_id != state.upstream_item_id || call_id.is_some_and(|call_id| call_id != state.call_id) { + return Err(search::invalid_upstream_search_call().into()); + } + Ok(()) +} + +fn validate_active_tool_search_done_name(frame: &EventFrame) -> ExecutorResult<()> { + if frame + .wire + .rest + .get("name") + .is_some_and(|name| name.as_str() != Some("tool_search")) + { + return Err(search::invalid_upstream_search_call().into()); + } + Ok(()) +} + +fn validate_wire_output_index(frame: &EventFrame, output_index: u32) -> ExecutorResult<()> { + if frame.wire.output_index != Some(u64::from(output_index)) { + return Err(search::invalid_upstream_search_call().into()); + } + Ok(()) +} + +fn search_arguments(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult> { + let OutputItem::ToolSearchCall(public) = + search::public_output_item(&call.item.id, &call.item.call_id, call.arguments())? + else { + return Err(search::invalid_upstream_search_call().into()); + }; + Ok(public.arguments) +} + +fn tool_search_frame(event_type: SSEEventType, output_index: u32, item: Value) -> ExecutorResult { + let mut frame = synthetic_event(event_type, [("item".to_owned(), item)])?; + frame.wire.output_index = Some(u64::from(output_index)); + Ok(frame) +} + fn custom_added_frame(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult { custom_frame( SSEEventType::OutputItemAdded, @@ -414,6 +803,15 @@ fn ensure_function_call_size(arguments: &str) -> ExecutorResult<()> { Ok(()) } +fn ensure_function_call_size_for(current: usize, additional: usize) -> ExecutorResult<()> { + if current.saturating_add(additional) > MAX_PENDING_FUNCTION_BYTES { + return Err(ExecutorError::StreamError(format!( + "function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" + ))); + } + Ok(()) +} + fn partial_custom_input(state: &mut CustomCallState, arguments: &str) -> ExecutorResult> { let input_start = if let Some(input_start) = state.input_start { input_start @@ -528,6 +926,574 @@ mod tests { .expect("SSE event") } + fn search_event_sequence(item_id: &str, call_id: &str, arguments: &str) -> [Value; 5] { + let split = arguments.len() / 2; + let (first, second) = arguments.split_at(split); + [ + serde_json::json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": item_id, "type": "function_call", "call_id": call_id, + "name": "tool_search", "arguments": "", "status": "in_progress"} + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": item_id, "call_id": call_id, "delta": first + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": item_id, "call_id": call_id, "delta": second + }), + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": item_id, "call_id": call_id, "name": "tool_search", "arguments": arguments + }), + serde_json::json!({ + "type": "response.output_item.done", "output_index": 0, + "item": {"id": item_id, "type": "function_call", "call_id": call_id, + "name": "tool_search", "arguments": arguments, "status": "completed"} + }), + ] + } + + #[test] + fn tool_search_stream_emits_only_public_added_and_done_with_stable_identity() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = FunctionSseTranslator::new(HashMap::from([ + ("tool_search".to_owned(), ToolType::ToolSearch), + ("weather".to_owned(), ToolType::Function), + ])); + let arguments = r#"{"query":"weather"}"#; + let mut frames = Vec::new(); + + for (index, event) in search_event_sequence("fc_search", "call_search", arguments) + .into_iter() + .enumerate() + { + frames.extend(translate(&mut accumulator, &mut translator, &event).frames); + if index == 1 { + let ordinary = serde_json::json!({ + "type": "response.output_item.added", "output_index": 1, + "item": {"id": "fc_weather", "type": "function_call", "call_id": "call_weather", + "name": "weather", "arguments": "", "status": "in_progress"} + }); + frames.extend(translate(&mut accumulator, &mut translator, &ordinary).frames); + } + } + + assert_eq!( + frames.iter().map(|frame| frame.event_type).collect::>(), + [ + SSEEventType::OutputItemAdded, + SSEEventType::OutputItemAdded, + SSEEventType::OutputItemDone + ] + ); + let search_frames = frames + .iter() + .filter(|frame| frame.wire.output_index == Some(0)) + .collect::>(); + assert_eq!(search_frames.len(), 2); + assert_eq!(search_frames[0].wire.rest["item"]["type"], "tool_search_call"); + assert_eq!(search_frames[0].wire.rest["item"]["status"], "in_progress"); + assert_eq!(search_frames[0].wire.rest["item"]["arguments"], serde_json::json!({})); + assert_eq!(search_frames[1].wire.rest["item"]["type"], "tool_search_call"); + assert_eq!(search_frames[1].wire.rest["item"]["status"], "completed"); + assert_eq!( + search_frames[1].wire.rest["item"]["arguments"], + serde_json::json!({"query": "weather"}) + ); + assert_eq!(search_frames[0].wire.rest["item"]["id"], "tsc_search"); + assert_eq!( + search_frames[0].wire.rest["item"]["id"], + search_frames[1].wire.rest["item"]["id"] + ); + assert_eq!(search_frames[0].wire.rest["item"]["call_id"], "call_search"); + assert_eq!( + search_frames[0].wire.rest["item"]["call_id"], + search_frames[1].wire.rest["item"]["call_id"] + ); + assert_eq!(frames[1].wire.rest["item"]["type"], "function_call"); + + let blocking = search::public_output_item("fc_search", "call_search", arguments) + .expect("blocking translation uses the same identity helper"); + let blocking = serialize_to_value(&blocking).expect("blocking item serializes"); + let replay: crate::types::io::InputItem = + serde_json::from_value(blocking.clone()).expect("public item replays"); + let replay = serialize_to_value(&replay).expect("replay serializes"); + assert_eq!(blocking, search_frames[1].wire.rest["item"]); + assert_eq!(replay, search_frames[1].wire.rest["item"]); + } + + #[test] + fn tool_search_stream_rejects_malformed_arguments_and_empty_call_id() { + for (call_id, arguments) in [("call_search", "[1]"), ("call_search", "{"), ("", "{}")] { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut error = None; + for event in search_event_sequence("fc_search", call_id, arguments) { + match accumulator.process_sse_line_with_translator(&sse(&event), &mut translator) { + Ok(_) => {} + Err(found) => { + error = Some(found); + break; + } + } + } + assert!( + error + .expect("invalid synthetic search stream must fail") + .to_string() + .contains("invalid tool-search call") + ); + } + } + + #[test] + fn tool_search_stream_rejects_linkage_changes_second_call_and_premature_eof() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let added = &search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#)[0]; + translate(&mut accumulator, &mut translator, added); + + let wrong_item = serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": "fc_other", "delta": "{}" + }); + assert!( + accumulator + .process_sse_line_with_translator(&sse(&wrong_item), &mut translator) + .expect_err("changed item ID must fail") + .to_string() + .contains("invalid tool-search call") + ); + assert!( + translator.finish().is_err(), + "an unfinished search call must fail at EOF" + ); + + let mut unnamed_accumulator = ResponseAccumulator::new("resp_unnamed".to_owned(), None); + let mut unnamed_translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + for event in [ + serde_json::json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_expected", "type": "function_call", "call_id": "call_expected", + "arguments": "", "status": "in_progress"} + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": "fc_wrong", "delta": "{}" + }), + ] { + unnamed_accumulator + .process_sse_line_with_translator(&sse(&event), &mut unnamed_translator) + .expect("unnamed frames buffer before resolution"); + } + let resolving_done = serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_expected", "name": "tool_search", "arguments": "{}" + }); + assert!( + unnamed_accumulator + .process_sse_line_with_translator(&sse(&resolving_done), &mut unnamed_translator) + .expect_err("buffered delta item linkage must be validated") + .to_string() + .contains("invalid tool-search call") + ); + + let mut second_accumulator = ResponseAccumulator::new("resp_2".to_owned(), None); + let mut second_translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + for event in search_event_sequence("fc_first", "call_first", "{}") { + translate(&mut second_accumulator, &mut second_translator, &event); + } + let second_added = serde_json::json!({ + "type": "response.output_item.added", "output_index": 1, + "item": {"id": "fc_second", "type": "function_call", "call_id": "call_second", + "name": "tool_search", "arguments": "", "status": "in_progress"} + }); + assert!( + second_accumulator + .process_sse_line_with_translator(&sse(&second_added), &mut second_translator) + .expect_err("a second synthetic search call must fail") + .to_string() + .contains("invalid tool-search call") + ); + } + + #[test] + fn tool_search_stream_accepts_authoritative_done_without_argument_deltas() { + let mut accumulator = ResponseAccumulator::new("resp_done_only".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let events = search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#); + + let frames = [0, 3, 4] + .into_iter() + .flat_map(|index| translate(&mut accumulator, &mut translator, &events[index]).frames) + .collect::>(); + + assert_eq!( + frames.iter().map(|frame| frame.event_type).collect::>(), + [SSEEventType::OutputItemAdded, SSEEventType::OutputItemDone] + ); + assert_eq!( + frames[1].wire.rest["item"]["arguments"], + serde_json::json!({"query": "weather"}) + ); + assert!(translator.finish().is_ok()); + } + + #[test] + fn tool_search_stream_accepts_omitted_done_name_for_active_call() { + let mut accumulator = ResponseAccumulator::new("resp_omitted_done_name".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let arguments = r#"{"query": "add numbers"}"#; + let events = [ + serde_json::json!({ + "type": "response.output_item.added", "output_index": 1, + "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": "", "status": "in_progress"} + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 1, + "item_id": "fc_search", "delta": "{}" + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 1, + "item_id": "fc_search", "delta": "{\"query\": \"" + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 1, + "item_id": "fc_search", "delta": "add numbers" + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 1, + "item_id": "fc_search", "delta": "\"}" + }), + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 1, + "item_id": "fc_search", "arguments": arguments + }), + serde_json::json!({ + "type": "response.output_item.done", "output_index": 1, + "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": arguments, "status": "completed"} + }), + ]; + + let frames = events + .iter() + .flat_map(|event| translate(&mut accumulator, &mut translator, event).frames) + .collect::>(); + + assert_eq!( + frames.iter().map(|frame| frame.event_type).collect::>(), + [SSEEventType::OutputItemAdded, SSEEventType::OutputItemDone] + ); + assert_eq!(frames[0].wire.output_index, Some(1)); + assert_eq!(frames[1].wire.output_index, Some(1)); + assert_eq!(frames[0].wire.rest["item"]["id"], "tsc_search"); + assert_eq!(frames[0].wire.rest["item"]["call_id"], "call_search"); + assert_eq!(frames[0].wire.rest["item"]["execution"], "client"); + assert_eq!(frames[0].wire.rest["item"]["status"], "in_progress"); + assert_eq!(frames[1].wire.rest["item"]["id"], "tsc_search"); + assert_eq!(frames[1].wire.rest["item"]["call_id"], "call_search"); + assert_eq!(frames[1].wire.rest["item"]["execution"], "client"); + assert_eq!(frames[1].wire.rest["item"]["status"], "completed"); + assert_eq!( + frames[1].wire.rest["item"]["arguments"], + serde_json::json!({"query": "add numbers"}) + ); + assert!(translator.finish().is_ok()); + } + + #[test] + fn tool_search_stream_rejects_authoritative_shape_and_linkage_mismatches() { + let added = search_event_sequence("fc_search", "call_search", "{}")[0].clone(); + for (label, followup) in [ + ( + "wrong call id", + serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": "fc_search", "call_id": "call_other", "delta": "{}" + }), + ), + ( + "missing output index", + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "item_id": "fc_search", "call_id": "call_search", "delta": "{}" + }), + ), + ( + "wrong done name", + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_search", "call_id": "call_search", "name": "weather", + "arguments": "{}" + }), + ), + ( + "empty done name", + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_search", "call_id": "call_search", "name": "", "arguments": "{}" + }), + ), + ( + "null done name", + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_search", "call_id": "call_search", "name": null, "arguments": "{}" + }), + ), + ( + "non-string done name", + serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_search", "call_id": "call_search", "name": 7, "arguments": "{}" + }), + ), + ] { + let mut accumulator = ResponseAccumulator::new(format!("resp_{label}"), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + translate(&mut accumulator, &mut translator, &added); + + let error = accumulator + .process_sse_line_with_translator(&sse(&followup), &mut translator) + .expect_err(label); + assert!(error.is_invalid_upstream_tool_search(), "{label}: {error}"); + } + + for (label, invalid_added) in [ + ( + "namespace on added", + serde_json::json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "namespace": "tools", "arguments": "", "status": "in_progress"} + }), + ), + ( + "missing added output index", + serde_json::json!({ + "type": "response.output_item.added", + "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": "", "status": "in_progress"} + }), + ), + ] { + let mut accumulator = ResponseAccumulator::new(format!("resp_{label}"), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let error = accumulator + .process_sse_line_with_translator(&sse(&invalid_added), &mut translator) + .expect_err(label); + assert!(error.is_invalid_upstream_tool_search(), "{label}: {error}"); + } + + let mut accumulator = ResponseAccumulator::new("resp_index_collision".to_owned(), None); + let mut translator = FunctionSseTranslator::new(HashMap::from([ + ("tool_search".to_owned(), ToolType::ToolSearch), + ("weather".to_owned(), ToolType::Function), + ])); + let ordinary = serde_json::json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_weather", "type": "function_call", "call_id": "call_weather", + "name": "weather", "arguments": "", "status": "in_progress"} + }); + translate(&mut accumulator, &mut translator, &ordinary); + let error = accumulator + .process_sse_line_with_translator(&sse(&added), &mut translator) + .expect_err("search must not overwrite an active output index"); + assert!(error.is_invalid_upstream_tool_search(), "{error}"); + } + + #[test] + fn unfinished_search_ids_include_pending_candidates_with_other_loaded_tools_only_until_resolved() { + let tool_types = HashMap::from([ + ("tool_search".to_owned(), ToolType::ToolSearch), + ("weather".to_owned(), ToolType::Function), + ]); + let unnamed = serde_json::json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_candidate", "type": "function_call", "call_id": "call_candidate", + "arguments": "", "status": "in_progress"} + }); + + let mut pending_accumulator = ResponseAccumulator::new("resp_pending".to_owned(), None); + let mut pending_translator = FunctionSseTranslator::new(tool_types.clone()); + translate(&mut pending_accumulator, &mut pending_translator, &unnamed); + assert_eq!( + pending_translator.unfinished_search_item_ids(), + HashSet::from(["fc_candidate"]) + ); + + let mut ordinary_accumulator = ResponseAccumulator::new("resp_ordinary".to_owned(), None); + let mut ordinary_translator = FunctionSseTranslator::new(tool_types); + translate(&mut ordinary_accumulator, &mut ordinary_translator, &unnamed); + let resolved = serde_json::json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_candidate", "call_id": "call_candidate", "name": "weather", + "arguments": "{}" + }); + translate(&mut ordinary_accumulator, &mut ordinary_translator, &resolved); + assert!(ordinary_translator.unfinished_search_item_ids().is_empty()); + } + + #[test] + fn upstream_failure_may_terminate_an_incomplete_search_without_false_completion() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let added = &search_event_sequence("fc_search", "call_search", "{}")[0]; + translate(&mut accumulator, &mut translator, added); + let failed = serde_json::json!({ + "type": "response.failed", + "response": { + "id": "upstream_failed", "status": "failed", "usage": null, + "error": {"code": "provider_failure", "message": "provider stopped"}, + "incomplete_details": {"reason": "upstream_error"} + } + }); + let translated = translate(&mut accumulator, &mut translator, &failed); + + assert_eq!(translated.frames.len(), 1); + assert_eq!(translated.frames[0].event_type, SSEEventType::ResponseFailed); + assert!(translator.finish().is_ok()); + } + + #[test] + fn pending_function_stream_state_has_aggregate_byte_and_call_count_limits() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = FunctionSseTranslator::new(HashMap::new()); + for output_index in 0..MAX_PENDING_FUNCTION_CALLS { + let unnamed = serde_json::json!({ + "type": "response.output_item.added", "output_index": output_index, + "item": {"id": format!("fc_{output_index}"), "type": "function_call", + "call_id": format!("call_{output_index}"), "arguments": "", "status": "in_progress"} + }); + translate(&mut accumulator, &mut translator, &unnamed); + } + let over_count = serde_json::json!({ + "type": "response.output_item.added", "output_index": MAX_PENDING_FUNCTION_CALLS, + "item": {"id": "fc_over", "type": "function_call", "call_id": "call_over", + "arguments": "", "status": "in_progress"} + }); + let count_error = accumulator + .process_sse_line_with_translator(&sse(&over_count), &mut translator) + .expect_err("pending call count must be bounded"); + assert!(count_error.to_string().contains("pending calls")); + + let mut bytes_accumulator = ResponseAccumulator::new("resp_2".to_owned(), None); + let mut bytes_translator = FunctionSseTranslator::new(HashMap::new()); + let mut byte_error = None; + for output_index in 0..MAX_PENDING_FUNCTION_CALLS { + let unnamed = serde_json::json!({ + "type": "response.output_item.added", "output_index": output_index, + "item": {"id": format!("fc_bytes_{output_index}"), "type": "function_call", + "call_id": format!("call_bytes_{output_index}"), "arguments": "", "status": "in_progress"} + }); + if let Err(error) = + bytes_accumulator.process_sse_line_with_translator(&sse(&unnamed), &mut bytes_translator) + { + byte_error = Some(error); + break; + } + let delta = serde_json::json!({ + "type": "response.function_call_arguments.delta", "output_index": output_index, + "item_id": format!("fc_bytes_{output_index}"), "delta": "x".repeat(4 * 1024) + }); + match bytes_accumulator.process_sse_line_with_translator(&sse(&delta), &mut bytes_translator) { + Ok(_) => {} + Err(error) => { + byte_error = Some(error); + break; + } + } + } + assert!( + byte_error + .expect("aggregate pending bytes must be bounded") + .to_string() + .contains("unnamed function-call SSE exceeded") + ); + + let mut search_accumulator = ResponseAccumulator::new("resp_search_pending".to_owned(), None); + let mut search_translator = FunctionSseTranslator::new(HashMap::from([ + ("tool_search".to_owned(), ToolType::ToolSearch), + ("weather".to_owned(), ToolType::Function), + ])); + for output_index in 0..MAX_PENDING_FUNCTION_CALLS { + let unnamed = serde_json::json!({ + "type": "response.output_item.added", "output_index": output_index, + "item": {"id": format!("fc_search_{output_index}"), "type": "function_call", + "call_id": format!("call_search_{output_index}"), "arguments": "", "status": "in_progress"} + }); + translate(&mut search_accumulator, &mut search_translator, &unnamed); + } + let over_count = serde_json::json!({ + "type": "response.output_item.added", "output_index": MAX_PENDING_FUNCTION_CALLS, + "item": {"id": "fc_search_over", "type": "function_call", "call_id": "call_search_over", + "arguments": "", "status": "in_progress"} + }); + let error = search_accumulator + .process_sse_line_with_translator(&sse(&over_count), &mut search_translator) + .expect_err("search-active pending overflow must use invalid-search classification"); + assert!(error.is_invalid_upstream_tool_search(), "{error}"); + } + + #[test] + fn tool_search_argument_buffer_accepts_exact_limit_and_rejects_one_more_byte() { + let prefix = r#"{"query":""#; + let suffix = r#""}"#; + let exact_arguments = format!( + "{prefix}{}{suffix}", + "x".repeat(MAX_PENDING_FUNCTION_BYTES - prefix.len() - suffix.len()) + ); + assert_eq!(exact_arguments.len(), MAX_PENDING_FUNCTION_BYTES); + + let mut exact_accumulator = ResponseAccumulator::new("resp_exact".to_owned(), None); + let mut exact_translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let exact_events = search_event_sequence("fc_exact", "call_exact", &exact_arguments); + translate(&mut exact_accumulator, &mut exact_translator, &exact_events[0]); + assert!( + exact_accumulator + .process_sse_line_with_translator(&sse(&exact_events[1]), &mut exact_translator) + .is_ok() + ); + assert!( + exact_accumulator + .process_sse_line_with_translator(&sse(&exact_events[2]), &mut exact_translator) + .is_ok() + ); + + let over_arguments = format!("{exact_arguments}x"); + let mut over_accumulator = ResponseAccumulator::new("resp_over".to_owned(), None); + let mut over_translator = + FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let over_events = search_event_sequence("fc_over", "call_over", &over_arguments); + translate(&mut over_accumulator, &mut over_translator, &over_events[0]); + assert!( + over_accumulator + .process_sse_line_with_translator(&sse(&over_events[1]), &mut over_translator) + .is_ok() + ); + assert!( + over_accumulator + .process_sse_line_with_translator(&sse(&over_events[2]), &mut over_translator) + .expect_err("one byte beyond the search-call limit must fail") + .to_string() + .contains("invalid tool-search call") + ); + } + #[test] fn custom_function_arguments_are_emitted_incrementally() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); diff --git a/crates/agentic-server-core/src/executor/gateway.rs b/crates/agentic-server-core/src/executor/gateway.rs index a588657d..18926bbc 100644 --- a/crates/agentic-server-core/src/executor/gateway.rs +++ b/crates/agentic-server-core/src/executor/gateway.rs @@ -218,6 +218,7 @@ fn gateway_public_output( .mcp_tool_ref(&call.name) .map(|tool_ref| crate::tool::mcp::handler::output_item(call, output, status, tool_ref)), ToolType::Function + | ToolType::ToolSearch | ToolType::Custom | ToolType::CodexNamespace | ToolType::FileSearch @@ -295,6 +296,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::ToolSearch | ToolType::Custom | ToolType::CodexNamespace | ToolType::FileSearch @@ -420,6 +422,7 @@ pub(super) fn emit_gateway_start_events( } OutputItem::Message(_) | OutputItem::FunctionCall(_) + | OutputItem::ToolSearchCall(_) | OutputItem::CustomToolCall(_) | OutputItem::Reasoning(_) | OutputItem::Unknown => {} @@ -465,6 +468,7 @@ pub(super) fn emit_gateway_completed_events( ), OutputItem::Message(_) | OutputItem::FunctionCall(_) + | OutputItem::ToolSearchCall(_) | OutputItem::CustomToolCall(_) | OutputItem::Reasoning(_) | OutputItem::Unknown => continue, @@ -544,7 +548,7 @@ pub(super) fn append_output_items_to_input(input: &mut ResponsesInput, output_it pub(super) fn append_tool_outputs(ctx: &mut RequestContext, tool_outputs: Vec) { for output in tool_outputs { ctx.new_input_items.push(output.clone()); - append_input_item(&mut ctx.enriched_request.input, output); + append_input_item(&mut ctx.inference_request_mut().input, output); } } diff --git a/crates/agentic-server-core/src/executor/mod.rs b/crates/agentic-server-core/src/executor/mod.rs index 54549340..e56cb00b 100644 --- a/crates/agentic-server-core/src/executor/mod.rs +++ b/crates/agentic-server-core/src/executor/mod.rs @@ -10,6 +10,7 @@ mod messages_request; pub mod messages_stream; pub mod modes; pub mod persist; +mod prepare; pub mod rehydrate; pub mod request; @@ -27,6 +28,6 @@ pub use messages_request::{normalize_native_web_search_for_upstream, validate_na pub use messages_stream::run_messages_stream; pub use modes::{ConversationHandler, ResponseHandler}; pub use persist::{persist_response, persist_turn}; -pub use rehydrate::rehydrate_conversation; +pub use rehydrate::{rehydrate_conversation, rehydrate_for_execution}; pub use request::ExecutionContext; pub use request::RequestContext; diff --git a/crates/agentic-server-core/src/executor/modes/conversation.rs b/crates/agentic-server-core/src/executor/modes/conversation.rs index e5479fab..2543cf58 100644 --- a/crates/agentic-server-core/src/executor/modes/conversation.rs +++ b/crates/agentic-server-core/src/executor/modes/conversation.rs @@ -1,8 +1,6 @@ //! Conversation storage handler — owns all conversation store operations. -use crate::storage::{ - ConversationData, ConversationSnapshot, ConversationStore, InOutItem, ResponseMetadata, StorageError, -}; +use crate::storage::{ConversationData, ConversationSnapshot, ConversationStore, InOutItem, StorageError}; use crate::types::io::OutputItem; use crate::executor::error::{ExecutorError, ExecutorResult}; @@ -94,13 +92,14 @@ impl ConversationHandler { /// Persists one conversation turn — only the new items from this turn. /// /// Takes `ctx` and `output_items` by value so fields can be moved directly - /// into [`ResponseMetadata`] without cloning. The store tracks sequence + /// into [`crate::storage::ResponseMetadata`]. The store tracks sequence /// numbers and appends, so prior history must not be re-inserted. /// /// # Errors /// Returns `ExecutorError` if `conversation_id` is absent on the context, /// the store is disabled, or the database operation fails. pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { + let metadata = ctx.response_metadata(); let conversation_id = ctx .conversation_id .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for execute_turn".into()))?; @@ -108,14 +107,6 @@ impl ConversationHandler { .conversation_version .ok_or_else(|| ExecutorError::InvalidRequest("conversation version is required for execute_turn".into()))?; - let metadata = ResponseMetadata { - model: ctx.enriched_request.model, - previous_response_id: ctx.original_request.previous_response_id, - effective_tools: ctx.enriched_request.tools, - effective_tool_choice: ctx.enriched_request.tool_choice.unwrap_or_default(), - effective_instructions: ctx.enriched_request.instructions, - }; - let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len()); new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input)); new_items.extend(output_items.into_iter().map(InOutItem::Output)); @@ -140,7 +131,7 @@ impl ConversationHandler { #[cfg(test)] mod tests { use super::*; - use crate::storage::{ConversationVersion, create_pool_with_schema}; + use crate::storage::{ConversationVersion, ResponseMetadata, create_pool_with_schema}; use crate::types::io::ResponsesInput; use crate::types::request_response::RequestPayload; @@ -172,6 +163,9 @@ mod tests { RequestContext { enriched_request: req.clone(), original_request: req, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_test".into(), conversation_id: conversation_id.map(str::to_string), diff --git a/crates/agentic-server-core/src/executor/modes/response.rs b/crates/agentic-server-core/src/executor/modes/response.rs index 842634ed..08514f8a 100644 --- a/crates/agentic-server-core/src/executor/modes/response.rs +++ b/crates/agentic-server-core/src/executor/modes/response.rs @@ -1,6 +1,6 @@ //! Response storage handler — owns all response store operations. -use crate::storage::{InOutItem, ResponseData, ResponseMetadata, ResponseStore}; +use crate::storage::{InOutItem, ResponseData, ResponseStore}; use crate::types::io::OutputItem; use crate::executor::error::{ExecutorError, ExecutorResult}; @@ -63,19 +63,13 @@ impl ResponseHandler { /// Persists a response record — only the new items from this turn. /// /// Takes `ctx` and `output_items` by value so fields can be moved directly - /// into [`ResponseMetadata`] without cloning. Prior history must not be + /// into [`crate::storage::ResponseMetadata`]. Prior history must not be /// re-inserted; the response store records item IDs for this response only. /// /// # Errors /// Returns `ExecutorError` if the store is disabled or the database operation fails. pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { - let metadata = ResponseMetadata { - model: ctx.enriched_request.model, - previous_response_id: ctx.original_request.previous_response_id, - effective_tools: ctx.enriched_request.tools, - effective_tool_choice: ctx.enriched_request.tool_choice.unwrap_or_default(), - effective_instructions: ctx.enriched_request.instructions, - }; + let metadata = ctx.response_metadata(); let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len()); new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input)); @@ -128,6 +122,9 @@ mod tests { RequestContext { enriched_request: req.clone(), original_request: req, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_test".into(), conversation_id: None, diff --git a/crates/agentic-server-core/src/executor/persist.rs b/crates/agentic-server-core/src/executor/persist.rs index d67ab5e8..c596c51a 100644 --- a/crates/agentic-server-core/src/executor/persist.rs +++ b/crates/agentic-server-core/src/executor/persist.rs @@ -13,9 +13,7 @@ use tracing::error; #[must_use] pub(crate) fn should_persist(ctx: &RequestContext) -> bool { - ctx.original_request.store - || ctx.original_request.previous_response_id.is_some() - || ctx.original_request.conversation_id.is_some() + ctx.original_request.store || ctx.original_request.conversation_id.is_some() } pub(crate) async fn persist_if_needed( diff --git a/crates/agentic-server-core/src/executor/prepare.rs b/crates/agentic-server-core/src/executor/prepare.rs new file mode 100644 index 00000000..75ed186c --- /dev/null +++ b/crates/agentic-server-core/src/executor/prepare.rs @@ -0,0 +1,28 @@ +use crate::executor::error::ExecutorResult; +use crate::executor::request::RequestContext; +use crate::tool::ToolSearchState; + +/// Prepare the one pure request-scoped tool-search state after rehydration. +/// +/// Active state is shared by blocking and streaming execution, persistence, +/// continuation, replay, and compaction. +/// +/// # Errors +/// +/// Returns a client-visible configuration error for invalid state. +pub(crate) fn prepare_tool_search(ctx: &mut RequestContext) -> ExecutorResult<()> { + let state = ToolSearchState::build_with_loaded_tools( + &ctx.enriched_request, + ctx.tool_search_loaded_tools.as_deref().unwrap_or_default(), + ctx.original_request.tools.is_some(), + )?; + if !state.is_active() { + ctx.tool_search_state = Some(state); + return Ok(()); + } + + let private_request = state.private_inference_request(&ctx.enriched_request)?; + ctx.tool_search_state = Some(state); + ctx.tool_search_private_request = Some(Box::new(private_request)); + Ok(()) +} diff --git a/crates/agentic-server-core/src/executor/rehydrate.rs b/crates/agentic-server-core/src/executor/rehydrate.rs index 6b9fdf08..ab01fdd9 100644 --- a/crates/agentic-server-core/src/executor/rehydrate.rs +++ b/crates/agentic-server-core/src/executor/rehydrate.rs @@ -4,6 +4,7 @@ //! injecting them into the enriched request before it is forwarded to the LLM. use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::prepare::prepare_tool_search; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::storage::InOutItem; use crate::types::io::{InputItem, ResponsesInput, resolve_tool_choice, resolve_tools}; @@ -35,6 +36,9 @@ pub async fn rehydrate_conversation( let mut ctx = RequestContext { enriched_request: request, original_request, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items, response_id, conversation_id: None, @@ -61,6 +65,22 @@ pub async fn rehydrate_conversation( Ok(ctx) } +/// Rehydrate the complete public request history, then invoke the shared +/// state-preparation seam before registry construction. +/// +/// # Errors +/// +/// Returns rehydration errors or deterministic tool-search state-validation +/// errors. +pub async fn rehydrate_for_execution( + request: RequestPayload, + exec_ctx: &ExecutionContext, +) -> ExecutorResult { + let mut ctx = rehydrate_conversation(request, exec_ctx).await?; + prepare_tool_search(&mut ctx)?; + Ok(ctx) +} + /// Hydrates `ctx` from the previous response chain. /// /// Loads the stored response, rehydrates its history items, resolves effective @@ -76,16 +96,7 @@ async fn from_response(ctx: &mut RequestContext, exec_ctx: &ExecutionContext) -> ctx.enriched_request.previous_response_id = None; ctx.enriched_request.input = ResponsesInput::Items(items); - ctx.enriched_request.tools = resolve_tools( - ctx.original_request.tools.as_deref(), - stored.metadata.effective_tools.as_deref(), - ctx.original_request.tools.is_some(), - ); - ctx.enriched_request.tool_choice = Some(resolve_tool_choice( - ctx.original_request.tool_choice.as_ref(), - &stored.metadata.effective_tool_choice, - ctx.original_request.tool_choice.is_some(), - )); + apply_effective_settings(ctx, &stored.metadata); ctx.conversation_id = stored.conversation_id; Ok(()) } @@ -106,16 +117,36 @@ async fn from_conversation(ctx: &mut RequestContext, exec_ctx: &ExecutionContext exec_ctx.conv_handler.rehydrate_snapshot(ctx), )?; + let latest_response_metadata = snapshot.latest_response_metadata; let mut items = InOutItem::into_input_items(snapshot.items); items.reserve(ctx.new_input_items.len()); items.extend(ctx.new_input_items.iter().cloned()); ctx.enriched_request.input = ResponsesInput::Items(items); + if let Some(metadata) = latest_response_metadata.as_ref() { + apply_effective_settings(ctx, metadata); + } ctx.conversation_id = Some(conv_data.conversation_id); ctx.conversation_version = Some(snapshot.version); Ok(()) } +fn apply_effective_settings(ctx: &mut RequestContext, stored: &crate::storage::ResponseMetadata) { + let tools_explicitly_set = ctx.original_request.tools.is_some(); + ctx.enriched_request.tools = resolve_tools( + ctx.original_request.tools.as_deref(), + stored.effective_tools.as_deref(), + tools_explicitly_set, + ); + ctx.enriched_request.tool_choice = Some(resolve_tool_choice( + ctx.original_request.tool_choice.as_ref(), + &stored.effective_tool_choice, + ctx.original_request.tool_choice.is_some(), + )); + ctx.tool_search_loaded_tools + .clone_from(&stored.tool_search_loaded_tools); +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -125,6 +156,7 @@ mod tests { use crate::storage::{ ConversationStore, ConversationVersion, InOutItem, ResponseMetadata, ResponseStore, create_pool_with_schema, }; + use crate::tool::ToolError; use crate::types::request_response::RequestPayload; fn request(conversation_id: Option<&str>, previous_response_id: Option<&str>) -> RequestPayload { @@ -208,6 +240,154 @@ mod tests { Ok(()) } + #[tokio::test] + async fn execution_rehydration_prepares_function_only_blocking_private_request() { + let exec_ctx = execution_context(ConversationStore::disabled(), ResponseStore::disabled()); + let request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "find weather tools", + "store": false, + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }] + })) + .expect("valid tool-search request"); + + let ctx = rehydrate_for_execution(request, &exec_ctx) + .await + .expect("blocking store:false search is valid after preparation"); + + assert!( + ctx.tool_search_state + .as_ref() + .is_some_and(crate::tool::ToolSearchState::is_active) + ); + assert!(ctx.tool_search_private_request.is_some()); + } + + #[tokio::test] + async fn execution_preparation_validates_tool_search_after_full_rehydration() { + let pool = create_pool_with_schema(Some("sqlite://?mode=memory")) + .await + .expect("create response store"); + let response_store = ResponseStore::new(pool); + let orphan: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + })) + .expect("valid public output item"); + response_store + .persist( + "resp_search", + None, + vec![InOutItem::Input(orphan)], + &ResponseMetadata::default(), + ) + .await + .expect("seed prior response"); + let exec_ctx = execution_context(ConversationStore::disabled(), response_store); + + let error = rehydrate_for_execution(request(None, Some("resp_search")), &exec_ctx) + .await + .expect_err("orphan in stored public history must fail after rehydration"); + + assert!( + matches!(error, ExecutorError::Tool(ToolError::Config(ref message)) if message.contains("orphan")), + "unexpected error: {error}" + ); + assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn stored_public_search_call_pairs_with_new_output_after_rehydration() { + let pool = create_pool_with_schema(Some("sqlite://?mode=memory")) + .await + .expect("create response store"); + let response_store = ResponseStore::new(pool); + let stored_call: crate::types::io::OutputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_stored", + "call_id": "call_search_stored", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + })) + .expect("valid emitted public search call"); + let effective_tools = serde_json::from_value(serde_json::json!([ + { + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + } + ])) + .expect("valid effective public declarations"); + let metadata = ResponseMetadata { + effective_tools: Some(effective_tools), + ..ResponseMetadata::default() + }; + response_store + .persist( + "resp_stored_search", + None, + vec![InOutItem::Output(stored_call)], + &metadata, + ) + .await + .expect("persist public search call"); + let exec_ctx = execution_context(ConversationStore::disabled(), response_store); + let mut continuation = request(None, Some("resp_stored_search")); + continuation.input = serde_json::from_value(serde_json::json!([{ + "type": "tool_search_output", + "call_id": "call_search_stored", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }])) + .expect("valid new public search output"); + + let mut ctx = rehydrate_conversation(continuation, &exec_ctx) + .await + .expect("stored public call rehydrates before new output"); + assert!(ctx.tool_search_state.is_none()); + prepare_tool_search(&mut ctx).expect("stored continuation derives valid tool-search state"); + + let state = ctx + .tool_search_state + .as_ref() + .expect("valid state was prepared after rehydration"); + assert!(state.is_active()); + assert_eq!(state.loaded_public_tools().len(), 1); + assert!(matches!( + &state.loaded_public_tools()[0], + crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather" + )); + let private_input = serde_json::to_value( + &ctx.tool_search_private_request + .as_deref() + .expect("active state materializes a private inference request") + .input, + ) + .expect("prepared private history serializes"); + assert_eq!(private_input[0]["call_id"], "call_search_stored"); + assert_eq!(private_input[1]["call_id"], "call_search_stored"); + } + #[tokio::test] async fn previous_response_rehydration_has_no_conversation_version() -> Result<(), Box> { let pool = create_pool_with_schema(Some("sqlite://?mode=memory")).await?; diff --git a/crates/agentic-server-core/src/executor/request.rs b/crates/agentic-server-core/src/executor/request.rs index e7e1cc9b..a9766a71 100644 --- a/crates/agentic-server-core/src/executor/request.rs +++ b/crates/agentic-server-core/src/executor/request.rs @@ -6,12 +6,14 @@ use crate::error::Error; use crate::executor::modes::{ConversationHandler, ResponseHandler}; use crate::storage::backend::redact_database_urls; use crate::storage::{ - ConversationStore, ConversationVersion, DatabaseBackend, ResponseStore, create_pool_with_schema_and_configs, + ConversationStore, ConversationVersion, DatabaseBackend, ResponseMetadata, ResponseStore, + create_pool_with_schema_and_configs, }; -use crate::tool::{GatewayExecutor, GatewayExecutors}; +use crate::tool::{GatewayExecutor, GatewayExecutors, ToolSearchState}; use crate::types::io::InputItem; use crate::types::messages::GatewayToolMap; use crate::types::request_response::{RequestPayload, ResponsePayload}; +use crate::types::tools::ResponsesTool; /// Env var configuring client-tool → gateway-executor aliases for `/v1/messages` /// (e.g. `WebSearch=web_search`). Empty/unset means no aliases — client @@ -26,6 +28,16 @@ pub struct RequestContext { /// Enriched request with rehydrated conversation history injected into `.input`. /// This is the request forwarded to the LLM. pub enriched_request: RequestPayload, + /// Pure request-scoped tool-search views prepared after full rehydration. + /// Public state remains separate from the private model request. + pub tool_search_state: Option, + /// Private inference request prepared once for active tool search. + /// Blocking and streaming execution consume this exact instance while + /// `enriched_request` remains the public representation. + pub tool_search_private_request: Option>, + /// Public definitions known to have been loaded before compaction removed + /// their search call/output pair. + pub tool_search_loaded_tools: Option>, /// Only the new input items submitted by the client this turn (used for persistence). pub new_input_items: Vec, /// Our generated response ID (uuid7 with "resp_" prefix). @@ -38,6 +50,52 @@ pub struct RequestContext { } impl RequestContext { + #[must_use] + pub(crate) fn inference_request(&self) -> &RequestPayload { + self.tool_search_private_request + .as_deref() + .unwrap_or(&self.enriched_request) + } + + #[must_use] + pub(crate) fn inference_request_mut(&mut self) -> &mut RequestPayload { + self.tool_search_private_request + .as_deref_mut() + .unwrap_or(&mut self.enriched_request) + } + + /// Construct the effective public metadata shared by response and + /// conversation persistence. + #[must_use] + pub(crate) fn response_metadata(&self) -> ResponseMetadata { + let active_search = self.tool_search_state.as_ref().filter(|state| state.is_active()); + ResponseMetadata { + model: self.enriched_request.model.clone(), + previous_response_id: self.original_request.previous_response_id.clone(), + effective_tools: active_search + .and_then(|state| state.public_effective_tools().map(<[_]>::to_vec)) + .or_else(|| self.enriched_request.tools.clone()), + tool_search_loaded_tools: active_search.map(|state| state.loaded_public_tools().to_vec()), + effective_tool_choice: self.enriched_request.tool_choice.clone().unwrap_or_default(), + effective_instructions: self.enriched_request.instructions.clone(), + } + } + + /// Return the public effective declarations for an active tool-search + /// response envelope without request-scoped MCP credentials or discovery + /// state. `Some([])` distinguishes an active declaration-free replay from + /// an inactive request, so private upstream declarations can never pass + /// through unchanged. + #[must_use] + pub(crate) fn tool_search_response_tools(&self) -> Option> { + let state = self.tool_search_state.as_ref().filter(|state| state.is_active())?; + let mut tools = state.public_effective_tools().unwrap_or_default().to_vec(); + for tool in &mut tools { + tool.sanitize_for_persistence(); + } + Some(tools) + } + /// Inject our `response_id` and `conversation_id` into a `ResponsePayload` /// received from the LLM (which carries the upstream's own IDs). pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) { @@ -204,9 +262,75 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use super::{ExecutionContext, database_open_error}; + use super::{ExecutionContext, RequestContext, database_open_error}; use crate::executor::{ConversationHandler, ResponseHandler}; use crate::storage::{ConversationStore, DatabaseBackend, ResponseStore, create_pool_with_schema}; + use crate::tool::ToolSearchState; + use crate::types::request_response::RequestPayload; + use crate::types::tools::{McpDiscoveredToolParam, ResponsesTool}; + + #[test] + fn tool_search_response_tools_restore_public_declarations_without_mcp_secrets() { + let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "find weather", + "parallel_tool_calls": false, + "tools": [ + { + "type": "tool_search", + "execution": "client", + "description": "Search tools", + "parameters": {"type": "object"} + }, + { + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "headers": {"Authorization": "Bearer header-secret"}, + "authorization": "field-secret", + "defer_loading": true + } + ] + })) + .expect("request shape"); + let ResponsesTool::Mcp(mcp) = &mut request.tools.as_mut().expect("tools")[1] else { + panic!("expected MCP declaration") + }; + mcp.discovered_tools.push(McpDiscoveredToolParam { + server_label: "weather".to_owned(), + tool_name: "forecast".to_owned(), + internal_name: "mcp__weather__forecast".to_owned(), + tool: serde_json::from_value(serde_json::json!({ + "name": "forecast", + "inputSchema": {"type": "object"} + })) + .expect("discovered MCP tool"), + }); + let state = ToolSearchState::build(&request).expect("tool-search state"); + let context = RequestContext { + original_request: request.clone(), + enriched_request: request, + tool_search_state: Some(state), + tool_search_private_request: None, + tool_search_loaded_tools: None, + new_input_items: Vec::new(), + response_id: "resp_test".to_owned(), + conversation_id: None, + conversation_version: None, + }; + + let tools = + serde_json::to_value(context.tool_search_response_tools().expect("active tools")).expect("tools serialize"); + assert_eq!(tools[1]["server_description"], "Weather tools"); + assert_eq!(tools[1]["defer_loading"], true); + assert!(tools[1].get("headers").is_none()); + assert!(tools[1].get("authorization").is_none()); + assert!(tools[1].get("_agentic_discovered_tools").is_none()); + for secret in ["header-secret", "field-secret", "mcp__weather__forecast"] { + assert!(!tools.to_string().contains(secret)); + } + } #[test] fn database_errors_are_actionable_without_exposing_credentials() { diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index 90a48c80..a7a5cf39 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -15,7 +15,7 @@ use crate::executor::inference::{call_inference, fetch_response_json}; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::tool::ToolRegistry; use crate::types::request_response::ResponsePayload; -use crate::utils::common::serialize_to_string; +use crate::utils::common::{deserialize_from_str, serialize_to_string, serialize_to_value}; const MAX_DEFERRED_STREAM_BYTES: usize = 256 * 1024; @@ -36,15 +36,17 @@ pub(super) async fn fetch_blocking_payload( ctx: &RequestContext, exec_ctx: &ExecutionContext, auth: Option<&str>, + registry: &ToolRegistry, ) -> ExecutorResult { let url = exec_ctx.responses_url(); // Non-streaming request: stream=false -> full JSON body -> from_json. - let upstream_request = ctx.enriched_request.to_upstream_request(false)?; + let upstream_request = ctx.inference_request().to_upstream_request(false)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let body = fetch_response_json(upstream_json, &url, &exec_ctx.client, auth).await?; - - let acc = ResponseAccumulator::from_json(&body, ctx.conversation_id.as_deref())?; + let mut raw: Value = deserialize_from_str(&body).map_err(ExecutorError::JsonError)?; + registry.normalize_blocking_response(&mut raw)?; + let acc = ResponseAccumulator::from_value(raw, ctx.conversation_id.as_deref())?; let mut payload = acc.finalize( &ctx.enriched_request.model, ctx.original_request.previous_response_id.as_deref(), @@ -67,7 +69,7 @@ pub(super) async fn fetch_stream_payload( output_offset: usize, ) -> ExecutorResult { let url = exec_ctx.responses_url(); - let upstream_request = ctx.enriched_request.to_upstream_request(true)?; + let upstream_request = ctx.inference_request().to_upstream_request(true)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let mut line_stream = Box::pin(call_inference( upstream_json, @@ -77,7 +79,8 @@ 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 function_sse = FunctionSseTranslator::new(registry.tool_type_map()); + let mut function_sse = FunctionSseTranslator::new(registry.tool_type_map()) + .with_withheld_function_names(registry.withheld_function_names()); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); let mut deferred_bytes = 0; @@ -129,12 +132,29 @@ pub(super) async fn fetch_stream_payload( } } } + let unfinished_search_item_ids = function_sse + .unfinished_search_item_ids() + .into_iter() + .map(str::to_owned) + .collect::>(); + if stream.is_some() { + function_sse.finish()?; + } acc.finish_stream(); let mut payload = acc.finalize( &ctx.enriched_request.model, ctx.original_request.previous_response_id.as_deref(), ctx.original_request.instructions.as_deref(), ); + if matches!(payload.status.as_str(), "error" | "failed" | "incomplete") { + payload.output.retain(|item| { + !matches!( + item, + crate::types::io::OutputItem::FunctionCall(call) + if unfinished_search_item_ids.contains(&call.id) + ) + }); + } ctx.inject_ids(&mut payload); Ok(StreamPayload { payload, @@ -202,6 +222,7 @@ fn should_defer_stream_event(frame: &EventFrame, defer_from_output_index: Option fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_>) -> ExecutorResult { apply_context_response_ids(&mut frame.wire, emit_ctx.request); + restore_public_tool_search_response_tools(&mut frame.wire, emit_ctx.request)?; emit_ctx.registry.restore_stream_event_wire(&mut frame.wire); let emitted = emit_ctx.accumulator.process_event(frame, emit_ctx.output_offset); if emitted { @@ -210,6 +231,23 @@ fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_ Ok(emitted) } +fn restore_public_tool_search_response_tools(wire: &mut WireEvent, request: &RequestContext) -> ExecutorResult<()> { + let Some(response) = wire.rest.get_mut("response").and_then(Value::as_object_mut) else { + return Ok(()); + }; + if !response.contains_key("tools") { + return Ok(()); + } + let Some(tools) = request.tool_search_response_tools() else { + return Ok(()); + }; + response.insert( + "tools".to_owned(), + serialize_to_value(&tools).map_err(ExecutorError::JsonError)?, + ); + Ok(()) +} + fn emit_or_defer_stream_frame( mut frame: EventFrame, emit_ctx: &mut StreamEmitContext<'_>, @@ -326,6 +364,9 @@ mod tests { RequestContext { original_request: request.clone(), enriched_request: request, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: "resp_test".to_owned(), conversation_id: None, diff --git a/crates/agentic-server-core/src/storage/conversation.rs b/crates/agentic-server-core/src/storage/conversation.rs index 4792dec2..a95af9b5 100644 --- a/crates/agentic-server-core/src/storage/conversation.rs +++ b/crates/agentic-server-core/src/storage/conversation.rs @@ -3,6 +3,7 @@ use std::convert::TryFrom; use std::sync::Arc; +use super::backend::DatabaseBackend; use super::models::{conversation, item, response}; use super::pool::DbPool; use super::types::{ @@ -89,7 +90,24 @@ impl ConversationStore { /// Returns an error if a stored item is missing its sequence number or if the database query fails. pub async fn rehydrate_snapshot(&self, conversation_id: &str) -> StoreResult { let pool = self.pool()?; - let rows = item::get_items_by_conversation(pool, conversation_id).await?; + let mut tx = pool.begin().await?; + if DatabaseBackend::from_connection(tx.as_mut()) == DatabaseBackend::Postgres { + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut *tx) + .await?; + } + let rows = item::get_items_by_conversation_in_tx(&mut tx, conversation_id).await?; + let latest_item_id = rows.last().map(|row| row.id.as_str()); + let conversation_turns = response::get_conversation_turns_in_tx(&mut tx, conversation_id).await?; + let latest_response = latest_item_id.and_then(|latest_item_id| { + conversation_turns.into_iter().find(|response| { + response + .history_item_ids_vec() + .last() + .is_some_and(|item_id| item_id == latest_item_id) + }) + }); + tx.commit().await?; let mut last_sequence = None; for row in &rows { @@ -102,6 +120,7 @@ impl ConversationStore { Ok(ConversationSnapshot { items: rows.into_iter().filter_map(|row| row.as_inout()).collect(), version: ConversationVersion::from_last_sequence(last_sequence), + latest_response_metadata: latest_response.and_then(|row| row.metadata_as()), }) } diff --git a/crates/agentic-server-core/src/storage/models/item.rs b/crates/agentic-server-core/src/storage/models/item.rs index e6cc81a8..d0d7c8e1 100644 --- a/crates/agentic-server-core/src/storage/models/item.rs +++ b/crates/agentic-server-core/src/storage/models/item.rs @@ -232,6 +232,17 @@ pub async fn get_items_by_conversation(pool: &DbPool, conversation_id: &str) -> .await } +/// Get conversation items in sequence order within an existing transaction. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get_items_by_conversation_in_tx(tx: &mut DbTransaction<'_>, conversation_id: &str) -> DbResult> { + sqlx::query_as::<_, Item>("SELECT * FROM items WHERE conversation_id = $1 ORDER BY seq ASC") + .bind(conversation_id) + .fetch_all(&mut **tx) + .await +} + /// Returns the last stored item sequence for a conversation inside a transaction. /// /// # Errors diff --git a/crates/agentic-server-core/src/storage/models/response.rs b/crates/agentic-server-core/src/storage/models/response.rs index a613b82d..801f5d7f 100644 --- a/crates/agentic-server-core/src/storage/models/response.rs +++ b/crates/agentic-server-core/src/storage/models/response.rs @@ -67,6 +67,23 @@ pub async fn get(pool: &DbPool, id: &str) -> DbResult> { .await } +/// Get responses written by conversation turns within an existing transaction. +/// +/// Responses branched through `previous_response_id` retain the originating +/// conversation ID for response-chain rehydration, but are not conversation turns. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get_conversation_turns_in_tx( + tx: &mut DbTransaction<'_>, + conversation_id: &str, +) -> DbResult> { + sqlx::query_as::<_, Response>("SELECT * FROM responses WHERE conversation_id = $1 AND previous_response_id IS NULL") + .bind(conversation_id) + .fetch_all(&mut **tx) + .await +} + impl Response { /// Deserialize `history_item_ids` from JSON string to Vec. #[must_use] diff --git a/crates/agentic-server-core/src/storage/types/conversation.rs b/crates/agentic-server-core/src/storage/types/conversation.rs index 71c0f590..08419868 100644 --- a/crates/agentic-server-core/src/storage/types/conversation.rs +++ b/crates/agentic-server-core/src/storage/types/conversation.rs @@ -2,6 +2,7 @@ use super::super::models::Conversation as StorageDbConversation; use super::item::InOutItem; +use super::response::ResponseMetadata; /// Version of a conversation's stored item history. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -28,6 +29,11 @@ pub struct ConversationSnapshot { pub items: Vec, /// Version derived from the last stored item sequence. pub version: ConversationVersion, + /// Effective public settings from the latest item-bearing persisted turn. + /// + /// Item-free writes do not advance [`ConversationVersion`] and cannot be + /// correlated to a distinct conversation checkpoint without a schema pointer. + pub latest_response_metadata: Option, } /// Domain entity for a stored conversation. diff --git a/crates/agentic-server-core/src/storage/types/item.rs b/crates/agentic-server-core/src/storage/types/item.rs index 5159d4ea..c4a3b664 100644 --- a/crates/agentic-server-core/src/storage/types/item.rs +++ b/crates/agentic-server-core/src/storage/types/item.rs @@ -80,12 +80,20 @@ impl TryFrom<&InOutItem> for String { fn try_from(item: &InOutItem) -> Result { let (mut value, kind) = match item { - InOutItem::Input(input) => ( - serde_json::to_value(input).map_err(StorageError::Serialization)?, - ItemKind::Input, - ), + InOutItem::Input(input) => { + let mut persisted = input.clone(); + if let InputItem::ToolSearchOutput(output) = &mut persisted { + for tool in &mut output.tools { + tool.sanitize_for_persistence(); + } + } + ( + serialize_to_value(&persisted).map_err(StorageError::Serialization)?, + ItemKind::Input, + ) + } InOutItem::Output(output) => ( - serde_json::to_value(output).map_err(StorageError::Serialization)?, + serialize_to_value(output).map_err(StorageError::Serialization)?, ItemKind::Output, ), }; @@ -160,6 +168,33 @@ mod tests { assert!(json.contains("test")); } + #[test] + fn tool_search_output_persistence_sanitizes_mcp_credentials_without_changing_public_type() { + let input: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [{ + "type": "mcp", + "server_label": "private-server", + "server_description": "Private server", + "server_url": "https://mcp.example.test/mcp", + "headers": {"X-API-Key": "secret"}, + "authorization": "bearer-secret", + "defer_loading": true + }] + })) + .expect("valid public tool-search output"); + + let stored = String::try_from(&InOutItem::Input(input)).expect("serialize stored item"); + let value: Value = serde_json::from_str(&stored).expect("stored JSON"); + + assert_eq!(value["type"], "tool_search_output"); + assert_eq!(value["tools"][0]["server_label"], "private-server"); + assert!(value["tools"][0].get("headers").is_none()); + assert!(value["tools"][0].get("authorization").is_none()); + assert!(value["tools"][0].get("_agentic_discovered_tools").is_none()); + } + #[test] fn test_into_input_items_converts_output_messages() { let mut output = OutputMessage::new("out1", MessageStatus::Completed); diff --git a/crates/agentic-server-core/src/storage/types/response.rs b/crates/agentic-server-core/src/storage/types/response.rs index 9572f398..fc8bc428 100644 --- a/crates/agentic-server-core/src/storage/types/response.rs +++ b/crates/agentic-server-core/src/storage/types/response.rs @@ -8,7 +8,7 @@ use super::super::models::Response as StorageDbResponse; use super::errors::StorageError; use crate::types::io::ToolChoice; use crate::types::tools::ResponsesTool; -use crate::utils::common::serialize_to_string; +use crate::utils::common::{serialize_to_string, serialize_to_value}; /// Response metadata with effective configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -16,10 +16,24 @@ pub struct ResponseMetadata { pub model: String, pub previous_response_id: Option, pub effective_tools: Option>, + /// Public definitions whose deferred availability was resolved by tool search. + /// + /// This is separate from `effective_tools` so public `defer_loading` stays + /// unchanged while compaction may remove the call/output pair that loaded it. + pub tool_search_loaded_tools: Option>, pub effective_tool_choice: ToolChoice, pub effective_instructions: Option, } +impl PartialEq for ResponseMetadata { + fn eq(&self, other: &Self) -> bool { + // Tool wire models intentionally do not expose a broad `PartialEq` + // contract. Metadata equality follows their complete serialized public + // representation so `ConversationSnapshot` retains its existing API. + serialize_to_value(self).ok() == serialize_to_value(other).ok() + } +} + /// Domain entity for a stored LLM response. #[derive(Debug, Clone)] pub struct ResponseData { @@ -63,6 +77,11 @@ impl TryFrom<&ResponseMetadata> for String { tool.sanitize_for_persistence(); } } + if let Some(tools) = persisted.tool_search_loaded_tools.as_mut() { + for tool in tools { + tool.sanitize_for_persistence(); + } + } serialize_to_string(&persisted).map_err(StorageError::Serialization) } } @@ -117,6 +136,7 @@ mod tests { model: "gpt-4".to_string(), previous_response_id: Some("resp_1".to_string()), effective_tools: None, + tool_search_loaded_tools: None, effective_tool_choice: ToolChoice::Auto, effective_instructions: Some("be helpful".to_string()), }; @@ -132,7 +152,9 @@ mod tests { let mut tool = serde_json::from_value(serde_json::json!({ "type": "mcp", "server_label": "counter", + "server_description": "Counter tools", "server_url": "https://mcp.example.com/mcp", + "defer_loading": true, "headers": {"X-API-Key": "secret"}, "authorization": "bearer-secret", "require_approval": "never" @@ -154,7 +176,8 @@ mod tests { .expect("discovered MCP tool"), }); let metadata = ResponseMetadata { - effective_tools: Some(vec![tool]), + effective_tools: Some(vec![tool.clone()]), + tool_search_loaded_tools: Some(vec![tool]), ..ResponseMetadata::default() }; @@ -175,6 +198,16 @@ mod tests { assert!(tool.headers.is_none()); assert!(tool.authorization.is_none()); + assert_eq!(tool.server_description.as_deref(), Some("Counter tools")); + assert_eq!(tool.defer_loading, Some(true)); + + let loaded = persisted.tool_search_loaded_tools.expect("persisted loaded tools"); + let ResponsesTool::Mcp(loaded) = &loaded[0] else { + panic!("expected loaded MCP tool"); + }; + assert!(loaded.headers.is_none()); + assert!(loaded.authorization.is_none()); + assert!(loaded.discovered_tools.is_empty()); } #[test] @@ -183,6 +216,7 @@ mod tests { assert_eq!(metadata.model, ""); assert!(metadata.previous_response_id.is_none()); assert!(metadata.effective_tools.is_none()); + assert!(metadata.tool_search_loaded_tools.is_none()); assert!(metadata.effective_instructions.is_none()); } diff --git a/crates/agentic-server-core/src/tool/codex.rs b/crates/agentic-server-core/src/tool/codex.rs index 5130b8a6..6d7afd0c 100644 --- a/crates/agentic-server-core/src/tool/codex.rs +++ b/crates/agentic-server-core/src/tool/codex.rs @@ -1,45 +1,19 @@ +use std::borrow::Cow; use std::collections::HashMap; use serde_json::{Map, Value}; use crate::events::WireEvent; -use crate::types::io::{FunctionTool, FunctionToolCall, OutputItem, ToolChoice}; +use crate::types::io::{FunctionTool, FunctionToolCall, InputItem, OutputItem, ResponsesInput, ToolChoice}; use crate::types::tools::{CodexNamespaceMember, CodexNamespaceToolParam, NonEmptyToolName, ResponsesTool}; use crate::utils::common::serialize_to_value_or_custom_default; use super::handler::{ToolError, ToolHandler}; +use super::names::{model_visible_namespace_member_name, validate_model_visible_declared_names}; use super::registry::{ToolEntry, ToolType}; -// Upstream Responses-compatible backends only see flat function names. Prefix -// flattened Codex namespace members so generated names are recognizable, -// unlikely to collide with user functions, and can be restored to -// `{ namespace, name }` on the way back to the client. -pub const MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX: &str = "agentic_ns__"; -pub const MAX_MODEL_VISIBLE_TOOL_NAME_LEN: usize = 64; - -const HASHED_NAMESPACE_MEMBER_SUFFIX_LEN: usize = 18; - -fn stable_name_hash(value: &str) -> u64 { - const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; - const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; - - value.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| { - (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME) - }) -} - -#[must_use] -pub fn model_visible_namespace_member_name(namespace: &str, member: &str) -> String { - let full_name = format!("{MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}{namespace}__{member}"); - if full_name.chars().count() <= MAX_MODEL_VISIBLE_TOOL_NAME_LEN { - return full_name; - } - - let hash = stable_name_hash(&full_name); - let readable_len = MAX_MODEL_VISIBLE_TOOL_NAME_LEN - HASHED_NAMESPACE_MEMBER_SUFFIX_LEN; - let readable_prefix = full_name.chars().take(readable_len).collect::(); - format!("{readable_prefix}__{hash:016x}") -} +#[cfg(test)] +use super::names::{MAX_MODEL_VISIBLE_TOOL_NAME_LEN, MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}; /// Registers one `ToolEntry` per `Function` member of `p`, keyed by the /// member's already-flattened, model-visible name — callers must resolve @@ -119,41 +93,10 @@ impl NamespaceMap { #[derive(Default)] struct NamespaceMapBuilder { - top_level_registry_keys: HashMap, map: NamespaceMap, } impl NamespaceMapBuilder { - fn new(top_level_registry_keys: HashMap) -> Self { - Self { - top_level_registry_keys, - ..Self::default() - } - } - - fn validate_and_record_flat_member( - &mut self, - namespace_name: &str, - member_name: &str, - ) -> Result { - let flat_name = model_visible_namespace_member_name(namespace_name, member_name); - if let Some(tool_kind) = self.top_level_registry_keys.get(&flat_name) { - return Err(ToolError::Config(format!( - "codex namespace member {namespace_name}.{member_name} generates name {flat_name}, which collides with a declared {}", - tool_kind.description() - ))); - } - if let Some(existing) = self.map.calls.get(&flat_name) { - if existing.member.namespace != namespace_name || existing.member.name != member_name { - return Err(ToolError::Config(format!( - "codex namespace member {namespace_name}.{member_name} collides with {}.{} at generated name {flat_name}", - existing.member.namespace, existing.member.name - ))); - } - } - Ok(self.record_flat_member_with_flat_name(namespace_name, member_name, flat_name)) - } - fn record_flat_member_with_flat_name( &mut self, namespace_name: &str, @@ -222,14 +165,21 @@ impl CodexNamespaceHandler { /// collides with another declared function-call tool or with another /// namespace member. pub fn resolve_namespace_members(&self, tools: &[ResponsesTool]) -> Result, ToolError> { - let mut builder = NamespaceMapBuilder::new(typed_top_level_registry_keys(tools)); + validate_model_visible_declared_names(tools)?; + Ok(Self::resolve_namespace_members_after_validation(tools)) + } + + /// Rewrite namespace members after the shared declaration-name validation + /// has already succeeded at the caller's request boundary. + pub(crate) fn resolve_namespace_members_after_validation(tools: &[ResponsesTool]) -> Vec { + let mut builder = NamespaceMapBuilder::default(); tools .iter() .map(|tool| match tool { ResponsesTool::Namespace(namespace) => { - rename_namespace_members(namespace, &mut builder).map(ResponsesTool::Namespace) + ResponsesTool::Namespace(rename_namespace_members(namespace, &mut builder)) } - other => Ok(other.clone()), + other => other.clone(), }) .collect() } @@ -259,19 +209,7 @@ impl CodexNamespaceHandler { /// collides with another declared function-call tool or with another /// namespace member. pub fn validate_namespace_collisions(&self, tools: Option<&[ResponsesTool]>) -> Result<(), ToolError> { - let Some(tools) = tools else { - return Ok(()); - }; - let mut builder = NamespaceMapBuilder::new(typed_top_level_registry_keys(tools)); - for tool in tools { - let ResponsesTool::Namespace(namespace) = tool else { - continue; - }; - for member_name in typed_function_member_names(namespace) { - builder.validate_and_record_flat_member(&namespace.name, &member_name)?; - } - } - Ok(()) + tools.map_or(Ok(()), validate_model_visible_declared_names) } /// Resolves the request's `tool_choice` (defaulting to `ToolChoice::Auto` @@ -292,6 +230,31 @@ impl CodexNamespaceHandler { rewrite_tool_choice_with_map(tool_choice, map) } + /// Rewrite public namespaced function-call history to the exact flat names + /// from the request-scoped namespace map used for tool declarations. + #[must_use] + pub fn resolve_input<'a>( + &self, + map: Option<&NamespaceMap>, + input: Cow<'a, ResponsesInput>, + ) -> Cow<'a, ResponsesInput> { + let Some(map) = map else { + return input; + }; + let should_rewrite = matches!(&*input, ResponsesInput::Items(items) if items.iter().any(|item| { + matches!(item, InputItem::FunctionCall(call) + if call.namespace.as_deref().is_some_and(|namespace| { + map.mapping_for_member(namespace, &call.name).is_some() + })) + })); + if !should_rewrite { + return input; + } + let mut input = input.into_owned(); + rewrite_input_with_map(&mut input, map); + Cow::Owned(input) + } + pub fn restore_output_items(&self, output: &mut [OutputItem], map: Option<&NamespaceMap>) { let Some(map) = map else { return; @@ -355,44 +318,60 @@ fn namespace_map_from_tools(tools: Option<&[ResponsesTool]>) -> Result Result { +) -> CodexNamespaceToolParam { let function_member_names = typed_function_member_names(namespace); if function_member_names.is_empty() { tracing::debug!( namespace = %namespace.name, "namespace tool has no function members to rename for upstream" ); - return Ok(namespace.clone()); + return namespace.clone(); } let tools = namespace .tools .iter() .map(|member| { let CodexNamespaceMember::Function(function) = member else { - return Ok(member.clone()); + return member.clone(); }; - let flat_name_text = builder.validate_and_record_flat_member(&namespace.name, function.name.as_str())?; + let flat_name_text = model_visible_namespace_member_name(&namespace.name, function.name.as_str()); + builder.record_flat_member_with_flat_name(&namespace.name, function.name.as_str(), flat_name_text.clone()); let flat_name = NonEmptyToolName::try_from(flat_name_text.clone()) .expect("generated namespace member names include a non-empty prefix"); tracing::debug!( @@ -403,33 +382,14 @@ fn rename_namespace_members( ); let mut function = function.clone(); function.name = flat_name; - Ok(CodexNamespaceMember::Function(function)) + CodexNamespaceMember::Function(function) }) - .collect::, ToolError>>()?; + .collect(); - Ok(CodexNamespaceToolParam { + CodexNamespaceToolParam { tools, ..namespace.clone() - }) -} - -fn typed_top_level_registry_keys(tools: &[ResponsesTool]) -> HashMap { - tools - .iter() - .filter_map(|tool| { - let registry_key = match tool { - ResponsesTool::Function(function) => function.name.as_str().to_owned(), - ResponsesTool::WebSearch(_) => "web_search".to_owned(), - ResponsesTool::FileSearch(_) => "file_search".to_owned(), - ResponsesTool::CodeInterpreter(_) => "code_interpreter".to_owned(), - ResponsesTool::Mcp(_) - | ResponsesTool::Namespace(_) - | ResponsesTool::Custom(_) - | ResponsesTool::Unknown => return None, - }; - tool.tool_type().map(|tool_type| (registry_key, tool_type)) - }) - .collect() + } } fn typed_function_member_names(namespace: &CodexNamespaceToolParam) -> Vec { @@ -832,7 +792,7 @@ mod tests { #[cfg(debug_assertions)] #[should_panic(expected = "namespace collisions must be validated before recording namespace members")] fn namespace_map_builder_debug_asserts_when_member_collision_validation_is_skipped() { - let mut builder = NamespaceMapBuilder::new(HashMap::new()); + let mut builder = NamespaceMapBuilder::default(); assert_eq!( builder.record_flat_member_with_flat_name("a__b", "c", "agentic_ns__a__b__c".to_owned()), diff --git a/crates/agentic-server-core/src/tool/mcp/handler.rs b/crates/agentic-server-core/src/tool/mcp/handler.rs index bfc2e941..7fb7e35f 100644 --- a/crates/agentic-server-core/src/tool/mcp/handler.rs +++ b/crates/agentic-server-core/src/tool/mcp/handler.rs @@ -55,6 +55,19 @@ impl McpToolMap { .values() .any(|tool_ref| tool_ref.server_label == server_label) } + + pub(crate) fn resolves_call_before_load( + &self, + load_positions: &HashMap, + call_positions: &HashMap, + ) -> bool { + self.calls.iter().any(|(name, tool_ref)| { + load_positions + .get(&tool_ref.server_label) + .zip(call_positions.get(name)) + .is_some_and(|(load, call)| call < load) + }) + } } #[must_use] @@ -228,9 +241,10 @@ impl McpHandler { } #[must_use] - pub(crate) fn failed_list_tools_item(server_label: &str, error: &ToolError) -> McpListTools { + pub(crate) fn failed_list_tools_item(server_label: &str, _error: &ToolError) -> McpListTools { + tracing::warn!(server_label, "MCP server connection or tools/list failed"); let mut item = McpListTools::new(uuid7_str("mcpl_"), server_label, Vec::new()); - item.error = Some(error.to_string()); + item.error = Some(format!("MCP server '{server_label}' failed to connect or list tools")); item } @@ -592,6 +606,30 @@ mod tests { assert!(error.to_string().contains("timed out during tools/list")); } + #[test] + fn public_list_tools_failure_redacts_transport_and_request_secrets() { + let error = ToolError::Execution( + "failed https://url-user:url-password@mcp.example.test/private?token=query-secret \ + Authorization: Bearer authorization-secret X-Private-Token: header-secret" + .to_owned(), + ); + + let item = McpHandler::failed_list_tools_item("weather", &error); + let public_error = item.error.expect("public list failure"); + + assert!(public_error.contains("weather")); + for secret in [ + "mcp.example.test", + "url-user", + "url-password", + "query-secret", + "authorization-secret", + "header-secret", + ] { + assert!(!public_error.contains(secret), "public error leaked {secret}"); + } + } + #[test] fn mcp_tool_arguments_require_valid_json_object() { assert_eq!( diff --git a/crates/agentic-server-core/src/tool/mcp/pool.rs b/crates/agentic-server-core/src/tool/mcp/pool.rs index e82931a5..091dc7dd 100644 --- a/crates/agentic-server-core/src/tool/mcp/pool.rs +++ b/crates/agentic-server-core/src/tool/mcp/pool.rs @@ -59,19 +59,14 @@ impl McpClientPool { } => McpClient::connect_stdio(&command, &args, env.as_ref(), cwd.as_deref()).await, }; - match result { - Ok(client) => { - clients.insert(server_label, Arc::new(client)); - } - Err(error) => { - let error_message = error.to_string(); - tracing::warn!( - server_label = %server_label, - error = %error_message, - "failed to connect MCP server from config" - ); - connection_errors.insert(server_label, error_message); - } + if let Ok(client) = result { + clients.insert(server_label, Arc::new(client)); + } else { + tracing::warn!( + server_label = %server_label, + "failed to connect MCP server from config" + ); + connection_errors.insert(server_label, "MCP transport connection failed".to_owned()); } } @@ -99,12 +94,9 @@ fn server_entry_from_param(param: &McpToolParam) -> Option<(String, McpServerEnt }; if let Some(url) = clean_string(param.server_url.as_deref()) { - let url = match validate_request_server_url(&url) { - Ok(url) => url, - Err(reason) => { - tracing::warn!(server_label, url, reason, "MCP tool param server_url rejected"); - return None; - } + let Ok(url) = validate_request_server_url(&url) else { + tracing::warn!(server_label, "MCP tool param server_url rejected"); + return None; }; return Some(( diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index 1802d32b..87611eb3 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -9,15 +9,19 @@ pub mod executors; pub mod function; pub mod handler; pub mod mcp; +mod names; pub mod normalize; pub mod registry; +pub mod search; pub mod web_search; -pub use codex::{CodexNamespaceHandler, NamespaceMap, model_visible_namespace_member_name}; +pub use codex::{CodexNamespaceHandler, NamespaceMap}; pub use custom::CustomHandler; pub use executors::{GatewayExecutorRegistration, GatewayExecutors}; pub use function::FunctionHandler; pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput}; pub use mcp::{McpClient, McpClientPool, McpDiscoveredHandler, McpError, McpHandler, McpOperation, McpServerEntry}; +pub use names::model_visible_namespace_member_name; pub use registry::{GatewayDispatchResult, ToolEntry, ToolRegistry, ToolType}; +pub use search::ToolSearchState; pub use web_search::WebSearchHandler; diff --git a/crates/agentic-server-core/src/tool/names.rs b/crates/agentic-server-core/src/tool/names.rs new file mode 100644 index 00000000..13fd7ed5 --- /dev/null +++ b/crates/agentic-server-core/src/tool/names.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +use crate::types::tools::{CodexNamespaceMember, ResponsesTool}; + +use super::ToolError; + +pub const MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX: &str = "agentic_ns__"; +pub const MAX_MODEL_VISIBLE_TOOL_NAME_LEN: usize = 64; + +const HASHED_NAMESPACE_MEMBER_SUFFIX_LEN: usize = 18; + +fn stable_name_hash(value: &str) -> u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + value.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME) + }) +} + +#[must_use] +pub fn model_visible_namespace_member_name(namespace: &str, member: &str) -> String { + let full_name = format!("{MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}{namespace}__{member}"); + if full_name.chars().count() <= MAX_MODEL_VISIBLE_TOOL_NAME_LEN { + return full_name; + } + + let hash = stable_name_hash(&full_name); + let readable_len = MAX_MODEL_VISIBLE_TOOL_NAME_LEN - HASHED_NAMESPACE_MEMBER_SUFFIX_LEN; + let readable_prefix = full_name.chars().take(readable_len).collect::(); + format!("{readable_prefix}__{hash:016x}") +} + +enum DeclaredNameOrigin<'a> { + TopLevel { description: &'static str }, + NamespaceMember { namespace: &'a str, member: &'a str }, +} + +impl DeclaredNameOrigin<'_> { + fn description(&self) -> String { + match self { + Self::TopLevel { description } => (*description).to_owned(), + Self::NamespaceMember { namespace, member } => { + format!("Codex namespace member {namespace}.{member}") + } + } + } +} + +/// Validate the exact names that public declarations expose to the model. +/// +/// This pass is intentionally declaration-only and performs no MCP discovery. +/// Discovered MCP member collisions remain a post-`tools/list` registry check. +/// +/// # Errors +/// +/// Returns [`ToolError::Config`] when function, custom, built-in, or normalized +/// namespace-member declarations resolve to the same model-visible name. +pub(crate) fn validate_model_visible_declared_names(tools: &[ResponsesTool]) -> Result<(), ToolError> { + let mut names = HashMap::new(); + for tool in tools { + match tool { + ResponsesTool::Function(function) => { + record_name( + &mut names, + function.name.as_str(), + DeclaredNameOrigin::TopLevel { + description: "function tool", + }, + )?; + } + ResponsesTool::Custom(custom) => { + record_name( + &mut names, + custom.name.as_str(), + DeclaredNameOrigin::TopLevel { + description: "custom tool", + }, + )?; + } + ResponsesTool::WebSearch(_) => { + record_name( + &mut names, + "web_search", + DeclaredNameOrigin::TopLevel { + description: "web search tool", + }, + )?; + } + ResponsesTool::FileSearch(_) => { + record_name( + &mut names, + "file_search", + DeclaredNameOrigin::TopLevel { + description: "file search tool", + }, + )?; + } + ResponsesTool::CodeInterpreter(_) => { + record_name( + &mut names, + "code_interpreter", + DeclaredNameOrigin::TopLevel { + description: "code interpreter tool", + }, + )?; + } + ResponsesTool::Namespace(namespace) => { + for member in &namespace.tools { + let CodexNamespaceMember::Function(function) = member else { + continue; + }; + let name = model_visible_namespace_member_name(&namespace.name, function.name.as_str()); + record_name( + &mut names, + &name, + DeclaredNameOrigin::NamespaceMember { + namespace: &namespace.name, + member: function.name.as_str(), + }, + )?; + } + } + ResponsesTool::ToolSearch(_) | ResponsesTool::Mcp(_) | ResponsesTool::Unknown => {} + } + } + Ok(()) +} + +fn record_name<'a>( + names: &mut HashMap>, + name: &str, + origin: DeclaredNameOrigin<'a>, +) -> Result<(), ToolError> { + match names.entry(name.to_owned()) { + Entry::Vacant(entry) => { + entry.insert(origin); + Ok(()) + } + Entry::Occupied(existing) => { + let existing_description = existing.get().description(); + match origin { + DeclaredNameOrigin::NamespaceMember { namespace, member } => Err(ToolError::Config(format!( + "codex namespace member {namespace}.{member} at generated name {name}, which collides with a declared {existing_description}" + ))), + DeclaredNameOrigin::TopLevel { description } => Err(ToolError::Config(format!( + "{description} model-visible name '{name}' collides with declared {existing_description}" + ))), + } + } + } +} diff --git a/crates/agentic-server-core/src/tool/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index c76ae580..38390af8 100644 --- a/crates/agentic-server-core/src/tool/normalize.rs +++ b/crates/agentic-server-core/src/tool/normalize.rs @@ -28,6 +28,19 @@ impl ResponsesTool { "function tool config serialization failed".to_owned(), )), ), + Self::ToolSearch(param) => { + if param.description.trim().is_empty() { + return Err(ToolError::Config( + "tool_search description must not be empty or whitespace".to_owned(), + )); + } + if param.parameters.get("type").and_then(serde_json::Value::as_str) != Some("object") { + return Err(ToolError::Config( + "tool_search parameters must declare top-level JSON Schema type 'object'".to_owned(), + )); + } + Ok(()) + } Self::Mcp(param) => serialize_to_value_or_custom_default( param, "MCP tool config serialization failed", @@ -57,6 +70,7 @@ impl ResponsesTool { pub fn tool_type(&self) -> Option { match self { Self::Function(_) => Some(ToolType::Function), + Self::ToolSearch(_) => Some(ToolType::ToolSearch), Self::Mcp(_) => Some(ToolType::Mcp), Self::WebSearch(_) => Some(ToolType::WebSearch), Self::FileSearch(_) => Some(ToolType::FileSearch), @@ -97,6 +111,10 @@ impl ResponsesTool { |param| FunctionHandler.normalize(¶m).into_iter().take(1).collect(), vec![], ), + Self::ToolSearch(_) => { + tracing::debug!("tool_search declaration skipped until request-scoped state preparation"); + vec![] + } Self::Mcp(p) => serialize_to_value_or_custom_default( p, "MCP tool config serialization failed", diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 0ce3dae6..2f9945e6 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -11,19 +11,22 @@ use super::executors::GatewayExecutors; use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; +use super::names::validate_model_visible_declared_names; +use super::search; use super::web_search::insert_web_search_entry; -use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput}; +use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; use crate::events::WireEvent; use crate::types::io::OutputItem; use crate::types::io::output::{FunctionToolCall, McpListTools}; use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool}; -use crate::utils::common::serialize_to_value_or_custom_default; +use crate::utils::common::{serialize_to_value, serialize_to_value_or_custom_default}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolType { Function, + ToolSearch, Custom, CodexNamespace, Mcp, @@ -40,6 +43,7 @@ impl ToolType { pub(crate) const fn description(self) -> &'static str { match self { Self::Function => "function tool", + Self::ToolSearch => "tool search", Self::Custom => "custom tool", Self::CodexNamespace => "Codex namespace tool", Self::Mcp => "MCP tool", @@ -51,7 +55,10 @@ impl ToolType { #[must_use] pub const fn is_gateway_owned(self) -> bool { - !matches!(self, Self::Function | Self::Custom | Self::CodexNamespace) + !matches!( + self, + Self::Function | Self::ToolSearch | Self::Custom | Self::CodexNamespace + ) } } @@ -162,6 +169,14 @@ fn insert_code_interpreter_entry( pub struct ToolRegistry { entries: HashMap, + /// Request-scoped public response translation is active even when a + /// declaration-free replay has no synthetic `tool_search` registry entry. + tool_search_translation_enabled: bool, + + /// Exact model-visible function names known publicly but withheld from the + /// effective private tool set. + withheld_function_names: HashSet, + /// Built once from the declared tools, so final payload and streaming event /// restoration don't rebuild it on every call. namespace_map: Option, @@ -198,10 +213,11 @@ impl ToolRegistry { let mut entries = HashMap::with_capacity(tools.len()); let mut mcp_tool_map = McpToolMap::default(); let mut mcp_list_tools_items = Vec::new(); - // Namespace members must be keyed by the same flat, model-visible name - // the model will call, so resolve them first — the same pure pass used - // to build the upstream request. - let resolved_tools = CodexNamespaceHandler.resolve_namespace_members(tools)?; + // Validate declaration-derived names before MCP I/O, then key namespace + // members by the same flat name used in the private inference request. + // Discovered MCP names retain their post-list collision pass. + validate_model_visible_declared_names(tools)?; + let resolved_tools = CodexNamespaceHandler::resolve_namespace_members_after_validation(tools); McpHandler::validate_server_labels(&resolved_tools)?; for (index, tool) in resolved_tools.iter().enumerate() { @@ -209,6 +225,9 @@ impl ToolRegistry { ResponsesTool::Function(p) => { insert_unique_tool_entries(&mut entries, |resolved| insert_function_entry(resolved, p))?; } + ResponsesTool::ToolSearch(_) => { + tracing::debug!("client-executed tool_search declaration is omitted from the dispatch registry"); + } ResponsesTool::Mcp(p) => { let tool_set = match executors.mcp_server_tools(p).await { Ok(tool_set) => tool_set, @@ -261,6 +280,8 @@ impl ToolRegistry { Ok(Self { entries, + tool_search_translation_enabled: false, + withheld_function_names: HashSet::new(), namespace_map, custom_tool_map, mcp_tool_map, @@ -274,10 +295,15 @@ impl ToolRegistry { } pub(crate) fn tool_type_map(&self) -> HashMap { - self.entries + let mut tool_types = self + .entries .iter() .map(|(name, entry)| (name.clone(), entry.tool_type)) - .collect() + .collect::>(); + if self.tool_search_translation_enabled { + tool_types.insert("tool_search".to_owned(), ToolType::ToolSearch); + } + tool_types } #[must_use] @@ -304,8 +330,157 @@ impl ToolRegistry { &self.mcp_list_tools_items } - pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) { + /// Reclassify only the synthetic function prepared by active request-scoped + /// tool-search state. An inactive ordinary function named `tool_search` + /// remains an ordinary client function. + pub(crate) fn classify_tool_search(&mut self, state: &ToolSearchState) -> Result<(), ToolError> { + self.tool_search_translation_enabled = state.is_active(); + self.withheld_function_names.clone_from(state.withheld_function_names()); + if !self.tool_search_translation_enabled { + return Ok(()); + } + if self + .entries + .keys() + .any(|name| self.withheld_function_names.contains(name)) + { + return Err(ToolError::Config( + "a loaded tool collides with a withheld function name".to_owned(), + )); + } + if self + .mcp_tool_map + .resolves_call_before_load(state.mcp_load_positions(), state.unqualified_call_positions()) + { + return Err(ToolError::Config( + "request history calls an MCP function before its server definition is loaded".to_owned(), + )); + } + let Some(synthetic) = state.synthetic_function() else { + return Ok(()); + }; + let entry = self.entries.get_mut(&synthetic.name).ok_or_else(|| { + ToolError::Config("prepared tool-search function is missing from the private registry".to_owned()) + })?; + if entry.tool_type != ToolType::Function || entry.handler.is_some() { + return Err(ToolError::Config( + "prepared tool-search function has invalid private registry ownership".to_owned(), + )); + } + entry.tool_type = ToolType::ToolSearch; + Ok(()) + } + + #[must_use] + fn has_tool_search(&self) -> bool { + self.tool_search_translation_enabled + } + + #[must_use] + pub(crate) fn withheld_function_names(&self) -> &HashSet { + &self.withheld_function_names + } + + /// Strictly convert a normalized blocking tool-search call to its public + /// representation before permissive response rehydration can discard or + /// default malformed fields. + pub(crate) fn normalize_blocking_response(&self, response: &mut Value) -> Result<(), ToolError> { + if !self.has_tool_search() { + return Ok(()); + } + let Some(items) = response.get_mut("output").and_then(Value::as_array_mut) else { + return Ok(()); + }; + let mut search_calls = 0_u8; + let mut public_ids = HashSet::with_capacity(items.len()); + let mut replacements = Vec::new(); + for (index, item) in items.iter().enumerate() { + let Some(object) = item.as_object() else { + continue; + }; + if object + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| self.withheld_function_names.contains(name)) + { + return Err(search::invalid_upstream_withheld_function_call()); + } + let reserved = object.get("name").and_then(Value::as_str) == Some("tool_search"); + let public_id = if reserved { + search_calls = search_calls.saturating_add(1); + if search_calls > 1 { + return Err(search::invalid_upstream_search_call()); + } + let replacement = normalize_raw_search_call(object)?; + let public_id = replacement["id"].as_str().unwrap_or_default().to_owned(); + replacements.push((index, replacement)); + public_id + } else { + object + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_default() + .to_owned() + }; + if !public_id.is_empty() && !public_ids.insert(public_id) { + return Err(search::invalid_upstream_search_call()); + } + } + for (index, replacement) in replacements { + items[index] = replacement; + } + Ok(()) + } + + /// Restore provider-normalized output types that are not already converted + /// by the strict raw blocking-response seam. + /// + /// # Errors + /// + /// Reserved for restoration failures reported by output adapters. + pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) -> Result<(), ToolError> { + if self.has_tool_search() { + let mut search_calls = 0_u8; + let mut public_ids = HashSet::with_capacity(output.len()); + let mut replacements = Vec::new(); + for (index, item) in output.iter().enumerate() { + if matches!(item, OutputItem::FunctionCall(call) if self.withheld_function_names.contains(&call.name)) { + return Err(search::invalid_upstream_withheld_function_call()); + } + let replacement = match item { + OutputItem::FunctionCall(call) if call.name == "tool_search" => { + search_calls = search_calls.saturating_add(1); + if search_calls > 1 + || call.status != crate::types::event::MessageStatus::Completed + || call.namespace.is_some() + { + return Err(search::invalid_upstream_search_call()); + } + Some(search::public_output_item(&call.id, &call.call_id, &call.arguments)?) + } + _ => None, + }; + let candidate = replacement.as_ref().unwrap_or(item); + let public_id = + serialize_to_value(candidate).map_err(|_| search::invalid_upstream_search_call())?["id"] + .as_str() + .filter(|id| !id.trim().is_empty()) + .unwrap_or_default() + .to_owned(); + if !public_id.is_empty() && !public_ids.insert(public_id) { + return Err(search::invalid_upstream_search_call()); + } + if let Some(replacement) = replacement { + replacements.push((index, replacement)); + } + } + for (index, replacement) in replacements { + output[index] = replacement; + } + } CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref()); + Ok(()) } pub fn restore_stream_event_wire(&self, wire: &mut WireEvent) -> bool { @@ -361,6 +536,11 @@ impl ToolRegistry { } } +fn normalize_raw_search_call(object: &serde_json::Map) -> Result { + let public = search::public_output_item_from_raw(object)?; + serialize_to_value(&public).map_err(|_| search::invalid_upstream_search_call()) +} + #[cfg(test)] mod tests { use super::*; @@ -397,6 +577,356 @@ mod tests { } } + #[tokio::test] + async fn tool_search_declaration_has_no_registry_entry_or_handler() { + let mut tools: Vec = serde_json::from_value(serde_json::json!([{ + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }])) + .expect("tool-search declaration"); + let mut executors = GatewayExecutors::default(); + + let registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors) + .await + .expect("inert declaration does not require a handler"); + + assert!(registry.lookup("tool_search").is_none()); + assert!(registry.entries.is_empty()); + } + + #[tokio::test] + async fn synthetic_tool_search_is_client_owned_never_dispatched_and_normalizes_blocking_output() { + let (request, state) = prepared_search_state(); + let mut tools = private_tools(&state, &request); + let mut executors = GatewayExecutors::default(); + let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors) + .await + .expect("synthetic function builds normally"); + + registry + .classify_tool_search(&state) + .expect("prepared synthetic entry is reclassified exactly once"); + let entry = registry.lookup("tool_search").expect("classified search entry"); + assert_eq!(entry.tool_type, ToolType::ToolSearch); + assert!(!entry.tool_type.is_gateway_owned()); + + let call: FunctionToolCall = serde_json::from_value(serde_json::json!({ + "type": "function_call", + "id": "fc_search_1", + "call_id": "call_search_1", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + })) + .expect("normalized call"); + assert!( + registry.dispatch(&call).await.is_none(), + "tool search has no gateway handler" + ); + + let mut response = serde_json::json!({"output": [{ + "type": "function_call", + "id": "fc_search_1", + "call_id": "call_search_1", + "name": "tool_search", + "namespace": null, + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }]}); + registry + .normalize_blocking_response(&mut response) + .expect("valid normalized search call converts once"); + assert_eq!( + response["output"][0], + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_search_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }) + ); + } + + #[tokio::test] + async fn raw_blocking_tool_search_normalization_is_strict_atomic_and_state_driven() { + let (request, state) = prepared_search_state(); + let mut tools = private_tools(&state, &request); + let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) + .await + .expect("registry"); + registry.classify_tool_search(&state).expect("classification"); + + let mut valid = serde_json::json!({"output": [{ + "type": "function_call", + "id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "namespace": null, + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }]}); + registry + .normalize_blocking_response(&mut valid) + .expect("valid raw call normalizes once"); + assert_eq!( + valid["output"][0], + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_search", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }) + ); + + let invalid_items = [ + serde_json::json!({"type":"custom_tool_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}"}), + serde_json::json!({"type":"function_call","call_id":"call_search","name":"tool_search","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":" ","call_id":"call_search","name":"tool_search","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","name":"tool_search","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"","name":"tool_search","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":7,"name":"tool_search","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":null,"status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"[]","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"null","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}","status":"in_progress"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}","status":7}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","namespace":"tools","arguments":"{}","status":"completed"}), + serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","namespace":7,"arguments":"{}","status":"completed"}), + ]; + for item in invalid_items { + let mut response = serde_json::json!({"output": [item]}); + assert!( + matches!( + registry.normalize_blocking_response(&mut response), + Err(ToolError::Execution(message)) if message == "upstream returned an invalid tool-search call" + ), + "malformed reserved raw call must fail atomically" + ); + } + + let raw_valid = serde_json::json!({ + "type":"function_call", "id":"fc_search", "call_id":"call_search", "name":"tool_search", + "namespace":null, "arguments":"{}", "status":"completed" + }); + let mut duplicate = serde_json::json!({"output": [raw_valid.clone(), raw_valid.clone()]}); + assert!(registry.normalize_blocking_response(&mut duplicate).is_err()); + let mut collision = serde_json::json!({"output": [ + raw_valid, + {"type":"message","id":"tsc_search","role":"assistant","content":[]} + ]}); + assert!(registry.normalize_blocking_response(&mut collision).is_err()); + + let mut ordinary_tools: Vec = serde_json::from_value(serde_json::json!([{ + "type": "function", "name": "tool_search", "parameters": {"type": "object"} + }])) + .expect("ordinary reserved-name function is valid while search is inactive"); + let ordinary = ToolRegistry::build_with_handlers(&mut ordinary_tools, &mut GatewayExecutors::default()) + .await + .expect("ordinary registry"); + let mut inactive_response = serde_json::json!({"output": [invalid_items_for_inactive()]}); + ordinary + .normalize_blocking_response(&mut inactive_response) + .expect("inactive ordinary function is never name-only validated as search"); + assert_eq!(inactive_response["output"][0]["type"], "function_call"); + } + + #[tokio::test] + async fn declaration_free_replay_enables_state_driven_raw_normalization() { + let (request, state) = replayed_search_state(); + assert!(state.is_active()); + assert!(state.synthetic_function().is_none()); + let mut tools = private_tools(&state, &request); + let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) + .await + .expect("loaded function registry"); + assert!(registry.lookup("tool_search").is_none()); + registry + .classify_tool_search(&state) + .expect("enable replay translation"); + + let mut valid = serde_json::json!({"output": [{ + "type": "function_call", "id": "fc_search", "call_id": "call_search", + "name": "tool_search", "namespace": null, "arguments": "{\"query\":\"news\"}", + "status": "completed" + }]}); + registry + .normalize_blocking_response(&mut valid) + .expect("active replay translates reserved call"); + assert_eq!(valid["output"][0]["type"], "tool_search_call"); + + let mut malformed = serde_json::json!({"output": [{ + "type": "function_call", "id": "fc_search", "call_id": "call_search", + "name": "tool_search", "namespace": null, "arguments": "{}", "status": "in_progress" + }]}); + assert!(matches!( + registry.normalize_blocking_response(&mut malformed), + Err(ToolError::Execution(_)) + )); + } + + #[tokio::test] + async fn withheld_namespace_guard_is_exact_not_prefix_based() { + let request = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "find a tool", + "tools": [ + { + "type": "tool_search", "execution": "client", "description": "Find a tool", + "parameters": {"type": "object"} + }, + { + "type": "namespace", "name": "weather", "tools": [{ + "type": "function", "name": "forecast", "defer_loading": true + }] + }, + { + "type": "function", "name": "agentic_ns__weather__ordinary_prefix_like", + "parameters": {"type": "object"} + } + ], + "store": false, + "stream": false + })) + .expect("active namespace request"); + let state = ToolSearchState::build(&request).expect("prepared namespace state"); + let mut tools = private_tools(&state, &request); + let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) + .await + .expect("private registry"); + registry.classify_tool_search(&state).expect("classification"); + + let mut ordinary = serde_json::json!({"output": [{ + "type": "function_call", "id": "fc_ordinary", "call_id": "call_ordinary", + "name": "agentic_ns__weather__ordinary_prefix_like", "arguments": "{}", "status": "completed" + }]}); + registry + .normalize_blocking_response(&mut ordinary) + .expect("unrelated prefix-like function remains ordinary"); + assert_eq!(ordinary["output"][0]["type"], "function_call"); + + let mut withheld = serde_json::json!({"output": [{ + "type": "function_call", "id": "fc_withheld", "call_id": "call_withheld", + "name": "agentic_ns__weather__forecast", "arguments": "{}", "status": "completed" + }]}); + assert!(matches!( + registry.normalize_blocking_response(&mut withheld), + Err(ToolError::Execution(_)) + )); + } + + fn invalid_items_for_inactive() -> Value { + serde_json::json!({ + "type": "function_call", + "id": "fc_ordinary", + "call_id": "", + "name": "tool_search", + "arguments": "[]" + }) + } + + fn private_tools( + state: &ToolSearchState, + request: &crate::types::request_response::RequestPayload, + ) -> Vec { + state + .private_inference_request(request) + .expect("prepared state materializes a private inference request") + .tools + .expect("private tools") + } + + fn prepared_search_state() -> (crate::types::request_response::RequestPayload, ToolSearchState) { + let request: crate::types::request_response::RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "find a tool", + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Search the client catalog", + "parameters": {"type": "object"} + }], + "store": false, + "stream": false + })) + .expect("public request"); + let state = ToolSearchState::build(&request).expect("prepared search state"); + (request, state) + } + + fn replayed_search_state() -> (crate::types::request_response::RequestPayload, ToolSearchState) { + let request = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": [ + { + "type": "tool_search_call", "id": "tsc_1", "call_id": "call_1", + "execution": "client", "arguments": {"query": "weather"}, "status": "completed" + }, + { + "type": "tool_search_output", "call_id": "call_1", "execution": "client", + "status": "completed", "tools": [{ + "type": "function", "name": "get_weather", "parameters": {"type": "object"}, + "defer_loading": true + }] + } + ], + "tools": [], + "store": false, + "stream": false + })) + .expect("declaration-free replay request"); + let state = ToolSearchState::build(&request).expect("prepared replay state"); + (request, state) + } + + #[test] + fn declaration_free_replay_keeps_stream_translation_active_without_a_dispatch_entry() { + let (_, state) = replayed_search_state(); + let mut registry = ToolRegistry::default(); + + registry.classify_tool_search(&state).expect("replay classification"); + + assert!(registry.lookup("tool_search").is_none()); + assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::ToolSearch)); + } + + #[test] + fn streaming_search_id_collision_is_atomic() { + let (_, state) = replayed_search_state(); + let mut registry = ToolRegistry::default(); + registry.classify_tool_search(&state).expect("replay classification"); + let mut output: Vec = serde_json::from_value(serde_json::json!([ + { + "type": "function_call", "id": "fc_same", "call_id": "call_search", + "name": "tool_search", "arguments": "{}", "status": "completed" + }, + { + "type": "tool_search_call", "id": "tsc_same", "call_id": "call_existing", + "execution": "client", "arguments": {}, "status": "completed" + } + ])) + .expect("output items"); + let before = serialize_to_value(&output).expect("before serializes"); + + let error = registry + .restore_final_payload_output(&mut output) + .expect_err("public ID collision must fail"); + + assert!(matches!( + error, + ToolError::Execution(message) if message == "upstream returned an invalid tool-search call" + )); + assert_eq!(serialize_to_value(&output).expect("after serializes"), before); + } + fn mixed_tool_declarations() -> Vec { serde_json::from_value(serde_json::json!([ { @@ -433,7 +963,9 @@ mod tests { arguments: "{}".to_owned(), status: MessageStatus::Completed, })]; - registry.restore_final_payload_output(&mut output); + registry + .restore_final_payload_output(&mut output) + .expect("ordinary namespace restoration succeeds"); let OutputItem::FunctionCall(call) = &output[0] else { panic!("expected restored function call"); }; diff --git a/crates/agentic-server-core/src/tool/search.rs b/crates/agentic-server-core/src/tool/search.rs new file mode 100644 index 00000000..430746b1 --- /dev/null +++ b/crates/agentic-server-core/src/tool/search.rs @@ -0,0 +1,1497 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use serde::Serialize; +use serde_json::Value; + +use crate::types::event::MessageStatus; +use crate::types::io::{ + FunctionTool, FunctionToolResultMessage, InputFunctionToolCall, InputItem, InputToolSearchCall, OutputItem, + ResponsesInput, ToolCallOutput, ToolChoice, ToolSearchCall, ToolSearchOutputMessage, +}; +use crate::types::request_response::{RequestPayload, ToolSearchReadiness}; +use crate::types::tools::{ + CodexNamespaceMember, CodexNamespaceToolParam, FunctionToolParam, NonEmptyToolName, ResponsesTool, + ToolSearchExecution, ToolSearchStatus, ToolSearchToolParam, +}; +use crate::utils::common::{deserialize_from_str, serialize_to_string, serialize_to_value}; + +use super::ToolError; +use super::names::validate_model_visible_declared_names; + +const SYNTHETIC_TOOL_NAME: &str = "tool_search"; + +/// Stable public identity used to compare definitions accumulated from search outputs. +/// +/// Equality remains type-aware while the state builder also indexes the visible name +/// separately, so returning the same name under a different supported kind is rejected. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +enum LoadedToolIdentity { + Function(String), + Namespace(String), + Mcp(String), +} + +impl LoadedToolIdentity { + fn name(&self) -> &str { + match self { + Self::Function(name) | Self::Namespace(name) | Self::Mcp(name) => name, + } + } + + const fn kind(&self) -> &'static str { + match self { + Self::Function(_) => "function", + Self::Namespace(_) => "namespace", + Self::Mcp(_) => "MCP server", + } + } +} + +struct DefinitionRecord { + identity: LoadedToolIdentity, + canonical: Value, + public_index: usize, + loaded: bool, + namespace_members: Option, +} + +struct NamespaceMemberRecord { + canonical: Value, + public_member_index: usize, + loaded: bool, +} + +struct NamespaceMemberRecords { + ordered: Vec, + indexes: HashMap, + unloaded_count: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ToolSearchActivity { + Inactive, + Active, +} + +struct PendingSearchCall { + call_id: String, +} + +struct DefinitionAccumulator<'a> { + public_tools: &'a mut Vec, + definitions: &'a mut Vec, + definition_indexes: &'a mut HashMap, + loaded_public_tools: &'a mut Vec, + withheld_function_names: &'a mut HashSet, + mcp_load_positions: &'a mut HashMap, + trusted_restored_identities: &'a HashSet, + prior_unknown_namespace_calls: HashMap>, + unqualified_call_positions: HashMap, + current_history_position: Option, +} + +struct DefinitionViews<'a> { + public_tools: &'a mut Vec, + definitions: &'a mut Vec, + definition_indexes: &'a mut HashMap, + loaded_public_tools: &'a mut Vec, + withheld_function_names: &'a mut HashSet, + mcp_load_positions: &'a mut HashMap, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum CatalogEntry { + Function { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + Namespace { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + Mcp { + server_label: String, + #[serde(skip_serializing_if = "Option::is_none")] + server_description: Option, + }, +} + +impl CatalogEntry { + fn display_name(&self) -> &str { + match self { + Self::Function { name, .. } | Self::Namespace { name, .. } => name, + Self::Mcp { server_label, .. } => server_label, + } + } + + fn description(&self) -> Option<&str> { + match self { + Self::Function { description, .. } | Self::Namespace { description, .. } => description.as_deref(), + Self::Mcp { server_description, .. } => server_description.as_deref(), + } + } +} + +/// Pure, request-scoped state derived from fully rehydrated public history. +/// +/// The state deliberately has no `Serialize` implementation and its `Debug` +/// output contains counts only. Canonical definitions can contain MCP +/// credentials and stay private to equality checks. +pub struct ToolSearchState { + activity: ToolSearchActivity, + public_effective_tools: Option>, + private_upstream_tools: Option>, + private_upstream_input: Option, + loaded_public_tools: Vec, + synthetic_function: Option, + withheld_function_names: HashSet, + mcp_load_positions: HashMap, + unqualified_call_positions: HashMap, +} + +impl fmt::Debug for ToolSearchState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ToolSearchState") + .field("activity", &self.activity) + .field("active", &self.is_active()) + .field( + "public_effective_tool_count", + &self.public_effective_tools.as_ref().map_or(0, Vec::len), + ) + .field( + "private_upstream_tool_count", + &self.private_upstream_tools.as_ref().map_or(0, Vec::len), + ) + .field("loaded_public_tool_count", &self.loaded_public_tools.len()) + .field("has_private_upstream_input", &self.private_upstream_input.is_some()) + .field("has_synthetic_function", &self.synthetic_function.is_some()) + .field("withheld_function_count", &self.withheld_function_names.len()) + .field("mcp_load_count", &self.mcp_load_positions.len()) + .field("unqualified_history_call_count", &self.unqualified_call_positions.len()) + .finish() + } +} + +impl Default for ToolSearchState { + fn default() -> Self { + Self { + activity: ToolSearchActivity::Inactive, + public_effective_tools: None, + private_upstream_tools: None, + private_upstream_input: None, + loaded_public_tools: Vec::new(), + synthetic_function: None, + withheld_function_names: HashSet::new(), + mcp_load_positions: HashMap::new(), + unqualified_call_positions: HashMap::new(), + } + } +} + +impl ToolSearchState { + /// Build deterministic public/private views from ordered public history. + /// + /// This function performs no network, storage, clock, random-ID, or + /// transport work. It runs in linear time in input items and definitions; + /// vectors retain declaration/history order and maps are lookup-only. + /// + /// # Errors + /// + /// Returns [`ToolError::Config`] for an invalid public declaration, + /// call/output ordering or linkage error, duplicate/conflicting definition, + /// or normalized-name collision. + pub fn build(request: &RequestPayload) -> Result { + Self::build_with_loaded_tools(request, &[], false) + } + + /// Build state with public loaded definitions restored from typed response + /// metadata when compaction has removed the original search pair. + /// + /// The restored definitions pass through the same validation and loading + /// logic as definitions in a public `tool_search_output`. + /// + /// # Errors + /// + /// Returns [`ToolError::Config`] under the same conditions as [`Self::build`]. + pub fn build_with_loaded_tools( + request: &RequestPayload, + restored_loaded_tools: &[ResponsesTool], + restore_only_declared: bool, + ) -> Result { + let active_input = request.input.model_input(); + let readiness = request.tool_search_readiness_for_input(active_input.as_ref())?; + if readiness == ToolSearchReadiness::Inactive { + return Ok(Self::default()); + } + + let input_items = match active_input.as_ref() { + ResponsesInput::Text(_) => &[][..], + ResponsesInput::Items(items) => items.as_slice(), + }; + let has_search_history = input_items + .iter() + .any(|item| matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))); + let declaration = request + .tools + .as_deref() + .unwrap_or_default() + .iter() + .find_map(|tool| match tool { + ResponsesTool::ToolSearch(declaration) => Some(declaration), + _ => None, + }); + if declaration.is_none() && !has_search_history { + return Err(ToolError::Config( + "defer_loading requires a tool_search declaration or replayed tool-search history".to_owned(), + )); + } + + let tools_were_present = request.tools.is_some(); + let mut public_tools = request.tools.clone().unwrap_or_default(); + let mut definitions = Vec::with_capacity(public_tools.len()); + let mut definition_indexes = HashMap::with_capacity(public_tools.len()); + index_initial_definitions(&public_tools, &mut definitions, &mut definition_indexes)?; + let mut withheld_function_names = + initial_withheld_function_names(&public_tools, &definitions, &definition_indexes)?; + let mut mcp_load_positions = HashMap::new(); + let mut unqualified_call_positions = HashMap::new(); + + let mut loaded_public_tools = Vec::new(); + let trusted_restored_identities = restore_loaded_definitions( + restored_loaded_tools, + DefinitionViews { + public_tools: &mut public_tools, + definitions: &mut definitions, + definition_indexes: &mut definition_indexes, + loaded_public_tools: &mut loaded_public_tools, + withheld_function_names: &mut withheld_function_names, + mcp_load_positions: &mut mcp_load_positions, + }, + restore_only_declared, + )?; + let private_upstream_input = prepare_history( + active_input.as_ref(), + DefinitionViews { + public_tools: &mut public_tools, + definitions: &mut definitions, + definition_indexes: &mut definition_indexes, + loaded_public_tools: &mut loaded_public_tools, + withheld_function_names: &mut withheld_function_names, + mcp_load_positions: &mut mcp_load_positions, + }, + &mut unqualified_call_positions, + &trusted_restored_identities, + )?; + + validate_model_visible_declared_names(&public_tools)?; + + let catalog = build_catalog(&public_tools, &definitions, &definition_indexes); + let synthetic_function = declaration + .map(|declaration| synthetic_function(declaration, &catalog)) + .transpose()?; + let private_tools = build_private_tools( + &public_tools, + &definitions, + &definition_indexes, + synthetic_function.as_ref(), + ); + let public_effective_tools = (tools_were_present || !public_tools.is_empty()).then_some(public_tools); + let private_upstream_tools = (tools_were_present || !private_tools.is_empty()).then_some(private_tools); + + Ok(Self { + activity: ToolSearchActivity::Active, + public_effective_tools, + private_upstream_tools, + private_upstream_input: Some(private_upstream_input), + loaded_public_tools, + synthetic_function, + withheld_function_names, + mcp_load_positions, + unqualified_call_positions, + }) + } + + #[must_use] + pub const fn is_active(&self) -> bool { + matches!(self.activity, ToolSearchActivity::Active) + } + + #[must_use] + pub fn public_effective_tools(&self) -> Option<&[ResponsesTool]> { + self.public_effective_tools.as_deref() + } + + /// Public definitions resolved by completed search outputs, in first-load order. + /// + /// This remains separate from `public_effective_tools`: an initially + /// deferred definition stays deferred publicly even after becoming loaded. + #[must_use] + pub fn loaded_public_tools(&self) -> &[ResponsesTool] { + &self.loaded_public_tools + } + + /// Private synthetic function declaration used by request-scoped registry and upstream normalization. + #[must_use] + pub const fn synthetic_function(&self) -> Option<&FunctionTool> { + self.synthetic_function.as_ref() + } + + #[must_use] + pub(crate) fn withheld_function_names(&self) -> &HashSet { + &self.withheld_function_names + } + + #[must_use] + pub(crate) fn mcp_load_positions(&self) -> &HashMap { + &self.mcp_load_positions + } + + #[must_use] + pub(crate) fn unqualified_call_positions(&self) -> &HashMap { + &self.unqualified_call_positions + } + + /// Materialize the private inference request for function, namespace, and + /// MCP tools from views prepared in the single state-building pass. The + /// public request is borrowed and never mutated. + /// + /// # Errors + /// + /// Returns [`ToolError::Config`] when the effective tool choice conflicts + /// with the prepared private tool set. + pub fn private_inference_request(&self, public: &RequestPayload) -> Result { + validate_effective_tool_choice(public.tool_choice.as_ref(), &self.withheld_function_names)?; + let mut private = public.clone(); + if let Some(input) = &self.private_upstream_input { + private.input.clone_from(input); + } + private.tools.clone_from(&self.private_upstream_tools); + Ok(private) + } +} + +fn validate_effective_tool_choice( + tool_choice: Option<&ToolChoice>, + withheld_function_names: &HashSet, +) -> Result<(), ToolError> { + let targets_withheld = match tool_choice { + Some(ToolChoice::Function { namespace, name }) => { + let model_name = namespace.as_deref().map_or_else( + || name.as_str().to_owned(), + |namespace| super::model_visible_namespace_member_name(namespace, name.as_str()), + ); + withheld_function_names.contains(&model_name) + } + Some(ToolChoice::AllowedTools { tools, .. }) => tools + .iter() + .any(|tool| tool.type_.as_str() == "function" && withheld_function_names.contains(tool.name.as_str())), + _ => false, + }; + if targets_withheld { + return Err(ToolError::Config( + "tool_choice targets a function before its definition is loaded".to_owned(), + )); + } + Ok(()) +} + +fn restore_loaded_definitions( + restored_loaded_tools: &[ResponsesTool], + views: DefinitionViews<'_>, + restore_only_declared: bool, +) -> Result, ToolError> { + let DefinitionViews { + public_tools, + definitions, + definition_indexes, + loaded_public_tools, + withheld_function_names, + mcp_load_positions, + } = views; + let no_trusted_restored_identities = HashSet::new(); + let mut trusted_restored_identities = HashSet::with_capacity(restored_loaded_tools.len()); + let mut accumulator = DefinitionAccumulator { + public_tools, + definitions, + definition_indexes, + loaded_public_tools, + withheld_function_names, + mcp_load_positions, + trusted_restored_identities: &no_trusted_restored_identities, + prior_unknown_namespace_calls: HashMap::new(), + unqualified_call_positions: HashMap::new(), + current_history_position: None, + }; + for tool in restored_loaded_tools { + let Some(tool) = restored_definition_for_load( + tool, + accumulator.public_tools, + accumulator.definitions, + accumulator.definition_indexes, + restore_only_declared, + )? + else { + continue; + }; + load_definition(&tool, &mut accumulator)?; + let identity = loaded_tool_identity(&tool)?.ok_or_else(|| { + ToolError::Config("stored tool-search availability contains an unsupported definition".to_owned()) + })?; + trusted_restored_identities.insert(identity); + } + Ok(trusted_restored_identities) +} + +fn restored_definition_for_load( + restored: &ResponsesTool, + public_tools: &[ResponsesTool], + definitions: &[DefinitionRecord], + definition_indexes: &HashMap, + restore_only_declared: bool, +) -> Result, ToolError> { + let identity = loaded_tool_identity(restored)?.ok_or_else(|| { + ToolError::Config("stored tool-search availability contains an unsupported definition".to_owned()) + })?; + let Some(record) = definition_indexes + .get(identity.name()) + .and_then(|index| definitions.get(*index)) + else { + return Ok((!restore_only_declared).then(|| restored.clone())); + }; + if record.identity != identity { + return Ok(Some(restored.clone())); + } + let declared = &public_tools[record.public_index]; + if matches!((restored, declared), (ResponsesTool::Mcp(_), ResponsesTool::Mcp(_))) { + let mut sanitized = declared.clone(); + sanitized.sanitize_for_persistence(); + if canonical_definition(&sanitized)? != canonical_definition(restored)? { + return Err(ToolError::Config(format!( + "loaded definition for identity '{}' conflicts with its existing type, schema, description, or configuration", + identity.name() + ))); + } + return Ok(Some(declared.clone())); + } + Ok(Some(restored.clone())) +} + +pub(crate) fn public_item_id(item_id: &str) -> String { + if item_id.strip_prefix("tsc_").is_some_and(|suffix| !suffix.is_empty()) { + return item_id.to_owned(); + } + if let Some(suffix) = item_id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) { + return format!("tsc_{suffix}"); + } + let domain_separated = format!("tool_search_item:{item_id}"); + format!("tsc_{:016x}", stable_hash(&domain_separated)) +} + +fn stable_hash(value: &str) -> u64 { + value.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +pub(crate) fn public_output_item(item_id: &str, call_id: &str, arguments: &str) -> Result { + if item_id.trim().is_empty() || call_id.trim().is_empty() { + return Err(invalid_upstream_search_call()); + } + let arguments = deserialize_from_str::(arguments) + .ok() + .and_then(|value| value.as_object().cloned()) + .ok_or_else(invalid_upstream_search_call)?; + Ok(OutputItem::ToolSearchCall(ToolSearchCall { + id: public_item_id(item_id), + call_id: call_id.to_owned(), + execution: ToolSearchExecution::Client, + arguments, + status: ToolSearchStatus::Completed, + })) +} + +pub(crate) fn public_output_item_from_raw(object: &serde_json::Map) -> Result { + if object.get("type").and_then(Value::as_str) != Some("function_call") + || object.get("name").and_then(Value::as_str) != Some(SYNTHETIC_TOOL_NAME) + || object.get("status").and_then(Value::as_str) != Some("completed") + || object.get("namespace").is_some_and(|namespace| !namespace.is_null()) + { + return Err(invalid_upstream_search_call()); + } + let item_id = required_non_blank_string(object.get("id"))?; + let call_id = required_non_blank_string(object.get("call_id"))?; + let arguments = required_non_blank_string(object.get("arguments"))?; + public_output_item(item_id, call_id, arguments) +} + +pub(crate) fn public_added_item(item_id: &str, call_id: &str) -> Result { + if item_id.trim().is_empty() || call_id.trim().is_empty() { + return Err(invalid_upstream_search_call()); + } + Ok(serde_json::json!({ + "id": public_item_id(item_id), + "type": "tool_search_call", + "status": "in_progress", + "arguments": {}, + "call_id": call_id, + "execution": "client", + })) +} + +fn required_non_blank_string(value: Option<&Value>) -> Result<&str, ToolError> { + value + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(invalid_upstream_search_call) +} + +pub(crate) fn invalid_upstream_search_call() -> ToolError { + ToolError::Execution("upstream returned an invalid tool-search call".to_owned()) +} + +pub(crate) fn invalid_upstream_withheld_function_call() -> ToolError { + ToolError::Execution("upstream returned a call for a function that has not been loaded".to_owned()) +} + +fn index_initial_definitions( + tools: &[ResponsesTool], + definitions: &mut Vec, + definition_indexes: &mut HashMap, +) -> Result<(), ToolError> { + for (public_index, tool) in tools.iter().enumerate() { + let Some(identity) = loaded_tool_identity(tool)? else { + continue; + }; + if definition_indexes.contains_key(identity.name()) { + return Err(ToolError::Config(format!( + "duplicate tool-search definition identity '{}'", + identity.name() + ))); + } + let index = definitions.len(); + definition_indexes.insert(identity.name().to_owned(), index); + definitions.push(definition_record(tool, identity, public_index, false)?); + } + Ok(()) +} + +fn definition_record( + tool: &ResponsesTool, + identity: LoadedToolIdentity, + public_index: usize, + dynamically_loaded: bool, +) -> Result { + let namespace_members = match tool { + ResponsesTool::Namespace(namespace) => Some(namespace_member_records(namespace, dynamically_loaded)?), + ResponsesTool::Function(_) | ResponsesTool::Mcp(_) => None, + ResponsesTool::ToolSearch(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => { + return Err(ToolError::Config( + "tool-search definition record received an unsupported tool".to_owned(), + )); + } + }; + let loaded = namespace_members + .as_ref() + .map_or(dynamically_loaded, |members| members.unloaded_count == 0); + Ok(DefinitionRecord { + identity, + canonical: canonical_definition(tool)?, + public_index, + loaded, + namespace_members, + }) +} + +fn namespace_member_records( + namespace: &CodexNamespaceToolParam, + dynamically_loaded: bool, +) -> Result { + if namespace.tools.is_empty() { + return Err(ToolError::Config( + "tool-search namespaces must contain at least one function member".to_owned(), + )); + } + let mut ordered = Vec::with_capacity(namespace.tools.len()); + let mut indexes = HashMap::with_capacity(namespace.tools.len()); + let mut unloaded_count = 0; + for (public_member_index, member) in namespace.tools.iter().enumerate() { + let CodexNamespaceMember::Function(function) = member else { + return Err(ToolError::Config( + "tool-search namespaces may contain only function members".to_owned(), + )); + }; + let name = function.name.as_str(); + if indexes.insert(name.to_owned(), ordered.len()).is_some() { + return Err(ToolError::Config(format!( + "duplicate namespace member identity '{}.{name}'", + namespace.name + ))); + } + let loaded = dynamically_loaded || function.defer_loading != Some(true); + unloaded_count += usize::from(!loaded); + ordered.push(NamespaceMemberRecord { + canonical: canonical_namespace_member(function)?, + public_member_index, + loaded, + }); + } + Ok(NamespaceMemberRecords { + ordered, + indexes, + unloaded_count, + }) +} + +fn initial_withheld_function_names( + public_tools: &[ResponsesTool], + definitions: &[DefinitionRecord], + definition_indexes: &HashMap, +) -> Result, ToolError> { + let mut withheld = HashSet::new(); + for tool in public_tools { + if let ResponsesTool::Function(function) = tool { + if function.defer_loading == Some(true) { + withheld.insert(function.name.as_str().to_owned()); + } + continue; + } + let ResponsesTool::Namespace(namespace) = tool else { + continue; + }; + let record = definition_indexes + .get(&namespace.name) + .and_then(|index| definitions.get(*index)) + .ok_or_else(|| ToolError::Config("namespace availability state is inconsistent".to_owned()))?; + let members = record + .namespace_members + .as_ref() + .ok_or_else(|| ToolError::Config("namespace availability state is inconsistent".to_owned()))?; + for member in &namespace.tools { + let CodexNamespaceMember::Function(function) = member else { + continue; + }; + let member_index = members + .indexes + .get(function.name.as_str()) + .ok_or_else(|| ToolError::Config("namespace availability state is inconsistent".to_owned()))?; + let is_withheld = !members.ordered[*member_index].loaded; + if is_withheld { + withheld.insert(super::model_visible_namespace_member_name( + &namespace.name, + function.name.as_str(), + )); + } + } + } + Ok(withheld) +} + +#[derive(Serialize)] +struct CanonicalToolSearchOutput<'a> { + tools: &'a [ModelVisibleLoadedTool<'a>], +} + +/// Typed model-output projection, deliberately separate from the raw +/// credential-sensitive definition retained for equality and later execution. +#[derive(Serialize)] +#[serde(untagged)] +enum ModelVisibleLoadedTool<'a> { + Function(ModelVisibleFunction<'a>), + Namespace(ModelVisibleNamespace<'a>), + Mcp(ModelVisibleMcp<'a>), +} + +#[derive(Serialize)] +struct ModelVisibleFunction<'a> { + #[serde(rename = "type")] + type_: &'static str, + #[serde(flatten)] + definition: &'a FunctionToolParam, +} + +#[derive(Serialize)] +struct ModelVisibleNamespace<'a> { + #[serde(rename = "type")] + type_: &'static str, + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, +} + +#[derive(Serialize)] +struct ModelVisibleMcp<'a> { + #[serde(rename = "type")] + type_: &'static str, + server_label: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + server_description: Option<&'a str>, +} + +fn prepare_history( + input: &ResponsesInput, + views: DefinitionViews<'_>, + unqualified_call_positions: &mut HashMap, + trusted_restored_identities: &HashSet, +) -> Result { + let ResponsesInput::Items(items) = input else { + return Ok(input.clone()); + }; + let mut private_items = Vec::with_capacity(items.len()); + let mut unresolved_call: Option = None; + let mut completed_call_ids = HashSet::new(); + let mut item_ids = HashSet::new(); + let DefinitionViews { + public_tools, + definitions, + definition_indexes, + loaded_public_tools, + withheld_function_names, + mcp_load_positions, + } = views; + let mut definition_accumulator = DefinitionAccumulator { + public_tools, + definitions, + definition_indexes, + loaded_public_tools, + withheld_function_names, + mcp_load_positions, + trusted_restored_identities, + prior_unknown_namespace_calls: HashMap::new(), + unqualified_call_positions: std::mem::take(unqualified_call_positions), + current_history_position: None, + }; + + for (position, item) in items.iter().enumerate() { + definition_accumulator.current_history_position = Some(position); + match item { + InputItem::ToolSearchCall(call) => { + private_items.push(prepare_search_call( + call, + &mut unresolved_call, + &completed_call_ids, + &mut item_ids, + )?); + } + InputItem::ToolSearchOutput(output) => { + private_items.push(prepare_search_output( + output, + &mut unresolved_call, + &mut completed_call_ids, + &mut definition_accumulator, + )?); + } + InputItem::FunctionCall(call) => { + ensure_history_call_is_available(call, &mut definition_accumulator)?; + private_items.push(item.clone()); + } + InputItem::Message(_) + | InputItem::FunctionCallOutput(_) + | InputItem::CustomToolCall(_) + | InputItem::CustomToolCallOutput(_) + | InputItem::Reasoning(_) + | InputItem::Compaction(_) + | InputItem::Unknown => private_items.push(item.clone()), + } + } + + if unresolved_call.is_some() { + return Err(ToolError::Config( + "unresolved tool_search_call requires a matching completed tool_search_output".to_owned(), + )); + } + *unqualified_call_positions = definition_accumulator.unqualified_call_positions; + Ok(ResponsesInput::Items(private_items)) +} + +fn ensure_history_call_is_available( + call: &InputFunctionToolCall, + definitions: &mut DefinitionAccumulator<'_>, +) -> Result<(), ToolError> { + if let Some(namespace) = call.namespace.as_deref() { + let member = definitions + .definition_indexes + .get(namespace) + .and_then(|index| definitions.definitions.get(*index)) + .filter(|record| matches!(record.identity, LoadedToolIdentity::Namespace(_))) + .and_then(|record| record.namespace_members.as_ref()) + .and_then(|members| members.indexes.get(&call.name).map(|index| &members.ordered[*index])); + match member { + Some(member) if !member.loaded => return Err(withheld_function_history_call()), + Some(_) => {} + None => { + definitions + .prior_unknown_namespace_calls + .entry(namespace.to_owned()) + .or_default() + .insert(call.name.clone()); + } + } + } else { + if definitions.withheld_function_names.contains(&call.name) { + return Err(withheld_function_history_call()); + } + let position = definitions + .current_history_position + .ok_or_else(|| ToolError::Config("tool-search history position is unavailable".to_owned()))?; + definitions + .unqualified_call_positions + .entry(call.name.clone()) + .or_insert(position); + } + Ok(()) +} + +fn withheld_function_history_call() -> ToolError { + ToolError::Config("request history calls a function before its definition is loaded".to_owned()) +} + +fn prepare_search_call( + call: &InputToolSearchCall, + unresolved_call: &mut Option, + completed_call_ids: &HashSet, + item_ids: &mut HashSet, +) -> Result { + if call.id.trim().is_empty() { + return Err(ToolError::Config("tool_search_call id must not be blank".to_owned())); + } + if call.call_id.trim().is_empty() { + return Err(ToolError::Config( + "tool_search_call call_id must not be blank".to_owned(), + )); + } + if !item_ids.insert(call.id.clone()) { + return Err(ToolError::Config("duplicate tool_search_call item id".to_owned())); + } + if unresolved_call.is_some() { + return Err(ToolError::Config( + "ambiguous tool-search history contains a call before the preceding call is resolved".to_owned(), + )); + } + if completed_call_ids.contains(call.call_id.as_str()) { + return Err(ToolError::Config("duplicate tool_search_call call_id".to_owned())); + } + let canonical_arguments = serialize_to_string(&Value::Object(call.arguments.clone())) + .map_err(|_| ToolError::Config("tool_search_call arguments could not be canonicalized safely".to_owned()))?; + *unresolved_call = Some(PendingSearchCall { + call_id: call.call_id.clone(), + }); + Ok(InputItem::FunctionCall(InputFunctionToolCall { + id: Some(call.id.clone()), + call_id: call.call_id.clone(), + name: SYNTHETIC_TOOL_NAME.to_owned(), + namespace: None, + arguments: canonical_arguments, + status: Some(MessageStatus::Completed), + })) +} + +fn prepare_search_output( + output: &ToolSearchOutputMessage, + unresolved_call: &mut Option, + completed_call_ids: &mut HashSet, + definition_accumulator: &mut DefinitionAccumulator<'_>, +) -> Result { + if output.call_id.trim().is_empty() { + return Err(ToolError::Config( + "tool_search_output call_id must not be blank".to_owned(), + )); + } + if completed_call_ids.contains(output.call_id.as_str()) { + return Err(ToolError::Config("duplicate tool_search_output call_id".to_owned())); + } + let Some(pending) = unresolved_call.take() else { + return Err(ToolError::Config( + "orphan tool_search_output has no unresolved call".to_owned(), + )); + }; + if pending.call_id != output.call_id { + return Err(ToolError::Config( + "tool_search_output call_id does not match the preceding unresolved call".to_owned(), + )); + } + for tool in &output.tools { + load_definition(tool, definition_accumulator)?; + } + let projected_tools = model_visible_output_tools(&output.tools)?; + let canonical_value = serialize_to_value(&CanonicalToolSearchOutput { + tools: &projected_tools, + }) + .map_err(|_| ToolError::Config("tool_search_output could not be canonicalized safely".to_owned()))?; + let canonical_output = serialize_to_string(&canonical_value) + .map_err(|_| ToolError::Config("tool_search_output could not be canonicalized safely".to_owned()))?; + completed_call_ids.insert(output.call_id.clone()); + Ok(InputItem::FunctionCallOutput(FunctionToolResultMessage { + call_id: output.call_id.clone(), + output: ToolCallOutput::Text(canonical_output), + })) +} + +fn model_visible_output_tools(tools: &[ResponsesTool]) -> Result>, ToolError> { + tools + .iter() + .map(|tool| match tool { + ResponsesTool::Function(definition) => Ok(ModelVisibleLoadedTool::Function(ModelVisibleFunction { + type_: "function", + definition, + })), + ResponsesTool::Namespace(namespace) => Ok(ModelVisibleLoadedTool::Namespace(ModelVisibleNamespace { + type_: "namespace", + name: &namespace.name, + description: namespace.description.as_deref(), + })), + ResponsesTool::Mcp(mcp) => Ok(ModelVisibleLoadedTool::Mcp(ModelVisibleMcp { + type_: "mcp", + server_label: &mcp.server_label, + server_description: mcp.server_description.as_deref(), + })), + ResponsesTool::ToolSearch(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => Err(ToolError::Config( + "tool_search_output contains an unsupported model-output definition".to_owned(), + )), + }) + .collect() +} + +fn load_definition(tool: &ResponsesTool, definitions: &mut DefinitionAccumulator<'_>) -> Result<(), ToolError> { + let identity = loaded_tool_identity(tool)? + .ok_or_else(|| ToolError::Config("tool_search_output contains an unsupported tool definition".to_owned()))?; + let canonical = canonical_definition(tool)?; + if let Some(index) = definitions.definition_indexes.get(identity.name()).copied() { + let record = &mut definitions.definitions[index]; + if record.identity != identity || record.canonical != canonical { + if record.identity == identity + && matches!(tool, ResponsesTool::Mcp(_)) + && definitions.trusted_restored_identities.contains(&identity) + { + let mut sanitized = definitions.public_tools[record.public_index].clone(); + sanitized.sanitize_for_persistence(); + if canonical_definition(&sanitized)? == canonical { + return Ok(()); + } + } + return Err(ToolError::Config(format!( + "loaded definition for identity '{}' conflicts with its existing type, schema, description, or configuration", + identity.name() + ))); + } + if let ResponsesTool::Namespace(returned) = tool { + return load_namespace_members( + returned, + record, + definitions.public_tools, + definitions.loaded_public_tools, + definitions.withheld_function_names, + &definitions.prior_unknown_namespace_calls, + &definitions.unqualified_call_positions, + ); + } + if !record.loaded { + if definitions.withheld_function_names.contains(record.identity.name()) + && definitions + .unqualified_call_positions + .contains_key(record.identity.name()) + { + return Err(withheld_function_history_call()); + } + let deferred_mcp = matches!( + &definitions.public_tools[record.public_index], + ResponsesTool::Mcp(mcp) if mcp.defer_loading == Some(true) + ); + if let LoadedToolIdentity::Mcp(server_label) = &record.identity + && deferred_mcp + && let Some(position) = definitions.current_history_position + { + definitions + .mcp_load_positions + .entry(server_label.clone()) + .or_insert(position); + } + record.loaded = true; + definitions.withheld_function_names.remove(record.identity.name()); + definitions + .loaded_public_tools + .push(definitions.public_tools[record.public_index].clone()); + } + return Ok(()); + } + + match tool { + ResponsesTool::Function(function) + if definitions + .unqualified_call_positions + .contains_key(function.name.as_str()) => + { + return Err(withheld_function_history_call()); + } + ResponsesTool::Namespace(namespace) => ensure_namespace_members_do_not_resolve_prior_calls( + namespace, + namespace.tools.iter(), + &definitions.prior_unknown_namespace_calls, + &definitions.unqualified_call_positions, + )?, + ResponsesTool::Mcp(mcp) => { + if let Some(position) = definitions.current_history_position { + definitions + .mcp_load_positions + .entry(mcp.server_label.clone()) + .or_insert(position); + } + } + _ => {} + } + let public_index = definitions.public_tools.len(); + definitions.public_tools.push(tool.clone()); + let index = definitions.definitions.len(); + definitions.definition_indexes.insert(identity.name().to_owned(), index); + definitions + .definitions + .push(definition_record(tool, identity, public_index, true)?); + definitions.loaded_public_tools.push(tool.clone()); + Ok(()) +} + +fn ensure_namespace_members_do_not_resolve_prior_calls<'a>( + namespace: &CodexNamespaceToolParam, + members: impl Iterator, + prior_unknown_namespace_calls: &HashMap>, + unqualified_call_positions: &HashMap, +) -> Result<(), ToolError> { + let prior_public_members = prior_unknown_namespace_calls.get(&namespace.name); + for member in members { + let CodexNamespaceMember::Function(function) = member else { + continue; + }; + let public_match = prior_public_members.is_some_and(|members| members.contains(function.name.as_str())); + let flat_name = super::model_visible_namespace_member_name(&namespace.name, function.name.as_str()); + if public_match || unqualified_call_positions.contains_key(&flat_name) { + return Err(withheld_function_history_call()); + } + } + Ok(()) +} + +fn load_namespace_members( + returned: &CodexNamespaceToolParam, + record: &mut DefinitionRecord, + public_tools: &mut [ResponsesTool], + loaded_public_tools: &mut Vec, + withheld_function_names: &mut HashSet, + prior_unknown_namespace_calls: &HashMap>, + unqualified_call_positions: &HashMap, +) -> Result<(), ToolError> { + if returned.tools.is_empty() { + return Err(ToolError::Config( + "tool_search_output namespaces must contain at least one function member".to_owned(), + )); + } + let ResponsesTool::Namespace(public_namespace) = &mut public_tools[record.public_index] else { + return Err(ToolError::Config( + "namespace identity conflicts with an existing non-namespace definition".to_owned(), + )); + }; + let members = record + .namespace_members + .as_mut() + .ok_or_else(|| ToolError::Config("namespace definition is missing prepared member state".to_owned()))?; + ensure_namespace_members_do_not_resolve_prior_calls( + returned, + returned.tools.iter().filter(|member| match member { + CodexNamespaceMember::Function(function) => !members.indexes.contains_key(function.name.as_str()), + CodexNamespaceMember::Unknown => true, + }), + prior_unknown_namespace_calls, + unqualified_call_positions, + )?; + let mut newly_loaded = Vec::new(); + for member in &returned.tools { + let CodexNamespaceMember::Function(returned_function) = member else { + return Err(ToolError::Config( + "tool_search_output namespaces may contain only function members".to_owned(), + )); + }; + let member_name = returned_function.name.as_str(); + let canonical = canonical_namespace_member(returned_function)?; + if let Some(member_index) = members.indexes.get(member_name).copied() { + let member_record = &mut members.ordered[member_index]; + if member_record.canonical != canonical { + return Err(ToolError::Config(format!( + "loaded namespace member '{}.{member_name}' conflicts with its existing schema, description, or configuration", + returned.name + ))); + } + if !member_record.loaded { + let unloaded_count = members.unloaded_count.checked_sub(1).ok_or_else(|| { + ToolError::Config("namespace member availability state is inconsistent".to_owned()) + })?; + member_record.loaded = true; + members.unloaded_count = unloaded_count; + withheld_function_names + .remove(&super::model_visible_namespace_member_name(&returned.name, member_name)); + newly_loaded.push(public_namespace.tools[member_record.public_member_index].clone()); + } + continue; + } + + let public_member_index = public_namespace.tools.len(); + public_namespace.tools.push(member.clone()); + members.indexes.insert(member_name.to_owned(), members.ordered.len()); + members.ordered.push(NamespaceMemberRecord { + canonical, + public_member_index, + loaded: true, + }); + newly_loaded.push(member.clone()); + } + if !newly_loaded.is_empty() { + let mut loaded_subset = public_namespace.clone(); + loaded_subset.tools = newly_loaded; + loaded_public_tools.push(ResponsesTool::Namespace(loaded_subset)); + } + record.loaded = members.unloaded_count == 0; + Ok(()) +} + +fn loaded_tool_identity(tool: &ResponsesTool) -> Result, ToolError> { + let identity = match tool { + ResponsesTool::Function(function) => LoadedToolIdentity::Function(function.name.as_str().to_owned()), + ResponsesTool::Namespace(namespace) => LoadedToolIdentity::Namespace(namespace.name.clone()), + ResponsesTool::Mcp(mcp) => LoadedToolIdentity::Mcp(mcp.server_label.clone()), + ResponsesTool::ToolSearch(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => return Ok(None), + }; + if identity.name().trim().is_empty() { + return Err(ToolError::Config(format!( + "{} definition identity must not be blank", + identity.kind() + ))); + } + if matches!( + &identity, + LoadedToolIdentity::Function(name) | LoadedToolIdentity::Namespace(name) + if name == SYNTHETIC_TOOL_NAME + ) { + return Err(ToolError::Config( + "model-visible tool name 'tool_search' is reserved while tool search is active".to_owned(), + )); + } + Ok(Some(identity)) +} + +fn canonical_definition(tool: &ResponsesTool) -> Result { + let projected = match tool { + ResponsesTool::Namespace(namespace) => { + let mut namespace = namespace.clone(); + namespace.tools.clear(); + ResponsesTool::Namespace(namespace) + } + other => other.clone(), + }; + serialize_to_value(&projected) + .map_err(|_| ToolError::Config("tool-search definition could not be compared safely".to_owned())) +} + +fn canonical_namespace_member(function: &FunctionToolParam) -> Result { + serialize_to_value(function) + .map_err(|_| ToolError::Config("namespace member definition could not be compared safely".to_owned())) +} + +fn build_catalog( + public_tools: &[ResponsesTool], + definitions: &[DefinitionRecord], + definition_indexes: &HashMap, +) -> Vec { + public_tools + .iter() + .filter_map(|tool| { + let identity = loaded_tool_identity(tool).ok().flatten()?; + let record = &definitions[*definition_indexes.get(identity.name())?]; + if record.loaded { + return None; + } + match tool { + ResponsesTool::Function(function) if function.defer_loading == Some(true) => { + Some(CatalogEntry::Function { + name: function.name.as_str().to_owned(), + description: function.description.clone(), + }) + } + ResponsesTool::Namespace(namespace) + if namespace_has_withheld_member(record.namespace_members.as_ref()) => + { + Some(CatalogEntry::Namespace { + name: namespace.name.clone(), + description: namespace.description.clone(), + }) + } + ResponsesTool::Mcp(mcp) if mcp.defer_loading == Some(true) => Some(CatalogEntry::Mcp { + server_label: mcp.server_label.clone(), + server_description: mcp.server_description.clone(), + }), + ResponsesTool::Function(_) + | ResponsesTool::Namespace(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::ToolSearch(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => None, + } + }) + .collect() +} + +/// Catalog prose deliberately follows the provider-characterization shape: +/// declaration text, then one ordered semicolon-delimited list of `name — +/// description` entries. It never uses schemas or execution configuration. +fn synthetic_description(declaration: &ToolSearchToolParam, catalog: &[CatalogEntry]) -> String { + if catalog.is_empty() { + return declaration.description.clone(); + } + let entries = catalog + .iter() + .map(|entry| { + entry.description().map_or_else( + || entry.display_name().to_owned(), + |description| { + let description = description.trim(); + if description.is_empty() { + entry.display_name().to_owned() + } else { + format!("{} — {description}", entry.display_name()) + } + }, + ) + }) + .collect::>() + .join("; "); + let noun = if catalog.len() == 1 { "entry" } else { "entries" }; + format!( + "{}. Available catalog {noun}: {entries}.", + declaration.description.trim().trim_end_matches('.') + ) +} + +fn synthetic_function(declaration: &ToolSearchToolParam, catalog: &[CatalogEntry]) -> Result { + let name = NonEmptyToolName::try_from(SYNTHETIC_TOOL_NAME) + .map_err(|_| ToolError::Config("reserved synthetic tool name is invalid".to_owned()))?; + let param = FunctionToolParam { + name, + description: Some(synthetic_description(declaration, catalog)), + parameters: Some(Value::Object(declaration.parameters.clone())), + strict: Some(true), + defer_loading: None, + extra: HashMap::new(), + }; + Ok(FunctionTool::from(¶m)) +} + +fn build_private_tools( + public_tools: &[ResponsesTool], + definitions: &[DefinitionRecord], + definition_indexes: &HashMap, + synthetic_function: Option<&FunctionTool>, +) -> Vec { + public_tools + .iter() + .filter_map(|tool| match tool { + ResponsesTool::ToolSearch(_) => synthetic_function.map(function_tool_as_response), + ResponsesTool::Function(_) | ResponsesTool::Namespace(_) | ResponsesTool::Mcp(_) => { + private_definition(tool, definitions, definition_indexes) + } + ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => Some(tool.clone()), + }) + .collect() +} + +fn private_definition( + tool: &ResponsesTool, + definitions: &[DefinitionRecord], + definition_indexes: &HashMap, +) -> Option { + let identity = loaded_tool_identity(tool).ok().flatten()?; + let loaded = definitions[*definition_indexes.get(identity.name())?].loaded; + match tool { + ResponsesTool::Function(function) if loaded || function.defer_loading != Some(true) => { + let mut function = function.clone(); + function.defer_loading = None; + Some(ResponsesTool::Function(function)) + } + ResponsesTool::Namespace(namespace) => private_namespace( + namespace, + definitions[*definition_indexes.get(identity.name())?] + .namespace_members + .as_ref(), + ) + .map(ResponsesTool::Namespace), + ResponsesTool::Mcp(mcp) if loaded || mcp.defer_loading != Some(true) => { + let mut mcp = mcp.clone(); + mcp.defer_loading = None; + Some(ResponsesTool::Mcp(mcp)) + } + ResponsesTool::Function(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::ToolSearch(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => None, + } +} + +fn private_namespace( + namespace: &CodexNamespaceToolParam, + member_records: Option<&NamespaceMemberRecords>, +) -> Option { + let member_records = member_records?; + let tools = namespace + .tools + .iter() + .filter_map(|member| match member { + CodexNamespaceMember::Function(function) + if member_records + .indexes + .get(function.name.as_str()) + .is_some_and(|index| member_records.ordered[*index].loaded) => + { + let mut function = function.clone(); + function.defer_loading = None; + Some(CodexNamespaceMember::Function(function)) + } + CodexNamespaceMember::Function(_) => None, + CodexNamespaceMember::Unknown => Some(CodexNamespaceMember::Unknown), + }) + .collect::>(); + (!tools.is_empty()).then(|| CodexNamespaceToolParam { + tools, + ..namespace.clone() + }) +} + +fn namespace_has_withheld_member(member_records: Option<&NamespaceMemberRecords>) -> bool { + member_records.is_some_and(|members| members.unloaded_count != 0) +} + +fn function_tool_as_response(function: &FunctionTool) -> ResponsesTool { + ResponsesTool::Function(FunctionToolParam { + name: NonEmptyToolName::try_from(function.name.as_str()) + .expect("the synthetic function uses a fixed non-empty name"), + description: function.description.clone(), + parameters: function.parameters.clone(), + strict: function.strict, + defer_loading: None, + extra: HashMap::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::tools::McpDiscoveredToolParam; + + #[test] + fn public_tool_search_item_ids_are_stable_and_domain_separated() { + assert_eq!(public_item_id("tsc_existing"), "tsc_existing"); + assert_eq!(public_item_id("fc_search_1"), "tsc_search_1"); + let first = public_item_id("provider-item-1"); + assert_eq!(first, public_item_id("provider-item-1")); + assert!(first.starts_with("tsc_")); + assert_ne!(first, crate::tool::custom::public_item_id("provider-item-1")); + } + + #[test] + fn internal_discovered_mcp_details_never_enter_model_visible_pair_projection() { + let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "store": false, + "parallel_tool_calls": false, + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }], + "input": [ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [{ + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "defer_loading": true + }] + } + ] + })) + .expect("valid tool-search request"); + let ResponsesInput::Items(items) = &mut request.input else { + panic!("test request uses item history") + }; + let InputItem::ToolSearchOutput(output) = &mut items[1] else { + panic!("test request contains a search output") + }; + let ResponsesTool::Mcp(mcp) = &mut output.tools[0] else { + panic!("test output returns MCP") + }; + mcp.discovered_tools.push(McpDiscoveredToolParam { + server_label: "weather".to_owned(), + tool_name: "discovered-tool-sentinel".to_owned(), + internal_name: "internal-name-sentinel".to_owned(), + tool: serde_json::from_value(serde_json::json!({ + "name": "discovered-tool-sentinel", + "description": "discovered-description-sentinel", + "inputSchema": { + "type": "object", + "properties": {"discovered-schema-sentinel": {"type": "string"}} + } + })) + .expect("valid discovered tool"), + }); + + let state = ToolSearchState::build(&request).expect("internal execution state remains valid"); + let private = state + .private_inference_request(&request) + .expect("active state materializes a private inference request"); + let private_input = serialize_to_string(&private.input).expect("private input serializes"); + for forbidden in [ + "_agentic_discovered_tools", + "discovered-tool-sentinel", + "internal-name-sentinel", + "discovered-description-sentinel", + "discovered-schema-sentinel", + ] { + assert!(!private_input.contains(forbidden), "private input leaked {forbidden}"); + } + } +} diff --git a/crates/agentic-server-core/src/types/io/input.rs b/crates/agentic-server-core/src/types/io/input.rs index 5a76bbdf..19b6c0d6 100644 --- a/crates/agentic-server-core/src/types/io/input.rs +++ b/crates/agentic-server-core/src/types/io/input.rs @@ -4,9 +4,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::types::event::MessageStatus; +use crate::types::tools::{ResponsesTool, ToolSearchExecution, ToolSearchStatus}; use crate::utils::common::deserialize_from_value; -use super::output::{CustomToolCall, FunctionToolCall, ReasoningOutput}; +use super::output::{CustomToolCall, FunctionToolCall, ReasoningOutput, ToolSearchCall}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputTextContent { @@ -167,6 +168,55 @@ impl From for InputFunctionToolCall { } } +pub(super) fn deserialize_non_blank_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + if value.trim().is_empty() { + return Err(serde::de::Error::custom("value must not be blank")); + } + Ok(value) +} + +/// A public model-generated tool-search call replayed as Responses input. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputToolSearchCall { + #[serde(deserialize_with = "deserialize_non_blank_string")] + pub id: String, + #[serde(deserialize_with = "deserialize_non_blank_string")] + pub call_id: String, + #[serde(default)] + pub execution: ToolSearchExecution, + pub arguments: serde_json::Map, + #[serde(default)] + pub status: ToolSearchStatus, +} + +impl From for InputToolSearchCall { + fn from(call: ToolSearchCall) -> Self { + Self { + id: call.id, + call_id: call.call_id, + execution: call.execution, + arguments: call.arguments, + status: call.status, + } + } +} + +/// Client-returned declarations resolving a public tool-search call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSearchOutputMessage { + #[serde(deserialize_with = "deserialize_non_blank_string")] + pub call_id: String, + #[serde(default)] + pub execution: ToolSearchExecution, + #[serde(default)] + pub status: ToolSearchStatus, + pub tools: Vec, +} + /// An opaque compacted context checkpoint accepted as Responses input. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompactionItem { @@ -204,6 +254,10 @@ pub enum InputItem { FunctionCall(InputFunctionToolCall), #[serde(rename = "function_call_output")] FunctionCallOutput(FunctionToolResultMessage), + #[serde(rename = "tool_search_call")] + ToolSearchCall(InputToolSearchCall), + #[serde(rename = "tool_search_output")] + ToolSearchOutput(ToolSearchOutputMessage), /// The public freeform invocation accepted from a client request. #[serde(rename = "custom_tool_call")] CustomToolCall(CustomToolCall), @@ -227,6 +281,8 @@ impl<'de> Deserialize<'de> for InputItem { None | Some("message") => deserialize_from_value(value).map(Self::Message), Some("function_call") => deserialize_from_value(value).map(Self::FunctionCall), Some("function_call_output") => deserialize_from_value(value).map(Self::FunctionCallOutput), + Some("tool_search_call") => deserialize_from_value(value).map(Self::ToolSearchCall), + Some("tool_search_output") => deserialize_from_value(value).map(Self::ToolSearchOutput), Some("custom_tool_call") => deserialize_from_value(value).map(Self::CustomToolCall), Some("custom_tool_call_output") => deserialize_from_value(value).map(Self::CustomToolCallOutput), Some("reasoning") => deserialize_from_value(value).map(Self::Reasoning), @@ -298,6 +354,18 @@ pub(crate) fn latest_compaction_window(items: &[InputItem]) -> Option bool { + matches!( + self, + Self::Items(items) + if items + .iter() + .any(|item| matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))) + ) + } + #[must_use] pub fn contains_compaction(&self) -> bool { matches!(self, Self::Items(items) if items.iter().any(|item| matches!(item, InputItem::Compaction(_)))) @@ -351,6 +419,123 @@ fn function_call_item_id(item_id: &str) -> Option { mod tests { use super::*; + #[test] + fn tool_search_replay_defaults_are_canonicalized() { + let call: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + })) + .expect("valid replayed search call"); + let output: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + })) + .expect("valid empty search result"); + + assert_eq!( + serde_json::to_value(call).expect("call serializes"), + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }) + ); + assert_eq!( + serde_json::to_value(output).expect("output serializes"), + serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "execution": "client", + "status": "completed", + "tools": [] + }) + ); + } + + #[test] + fn responses_input_detects_only_typed_tool_search_state() { + let search: ResponsesInput = serde_json::from_value(serde_json::json!([{ + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + }])) + .expect("typed search state"); + let ordinary: ResponsesInput = serde_json::from_value(serde_json::json!([{ + "type": "function_call_output", + "call_id": "call_1", + "output": "done" + }])) + .expect("ordinary function state"); + + assert!(search.contains_tool_search_state()); + assert!(!ordinary.contains_tool_search_state()); + } + + #[test] + fn tool_search_replay_rejects_invalid_known_shapes() { + for item in [ + serde_json::json!({ + "type": "tool_search_call", + "call_id": "call_search_1", + "arguments": {"query": "missing required item id"} + }), + serde_json::json!({ + "type": "tool_search_call", + "id": " ", + "call_id": "call_search_1", + "arguments": {"query": "blank item id"} + }), + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": " ", + "arguments": {"query": "blank call id"} + }), + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "execution": "server", + "arguments": {"query": "unsupported execution"} + }), + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": "not an object", + "status": "completed" + }), + serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "status": "in_progress", + "tools": [] + }), + serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1" + }), + ] { + assert!( + serde_json::from_value::(item).is_err(), + "malformed known tool-search item must not become Unknown" + ); + } + + let future: InputItem = serde_json::from_value(serde_json::json!({ + "type": "future_search_item", + "payload": {"opaque": true} + })) + .expect("unrelated future item remains forward-compatible"); + assert!(matches!(future, InputItem::Unknown)); + } + #[test] fn function_call_input_accepts_missing_status() { let item: InputItem = 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 648b9036..216d153c 100644 --- a/crates/agentic-server-core/src/types/io/mod.rs +++ b/crates/agentic-server-core/src/types/io/mod.rs @@ -6,13 +6,13 @@ pub mod usage; pub use input::{ CompactionItem, CustomToolCallOutputMessage, FunctionToolResultMessage, InputContent, InputFileContent, InputFunctionToolCall, InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, - ResponsesInput, ToolCallOutput, ToolOutputContent, + InputToolSearchCall, ResponsesInput, ToolCallOutput, ToolOutputContent, ToolSearchOutputMessage, }; pub use output::{ ApplyDone, CustomToolCall, FunctionToolCall, GatewayCallStatus, McpCall, McpCallError, McpCallStatus, McpToolExecutionError, McpToolExecutionErrorContent, OutputItem, OutputMessage, OutputTextContent, ReasoningOutput, - ReasoningTextContent, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, - WebSearchCall, WebSearchCallStatus, WebSearchSource, + ReasoningTextContent, ToolSearchCall, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, + WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, WebSearchSource, }; pub use tools::{AllowedTool, AllowedToolsMode, FunctionTool, ToolChoice}; pub(crate) use tools::{resolve_tool_choice, resolve_tools}; diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index 5ce571c2..39c2f9cc 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -5,11 +5,13 @@ use crate::events::EventPayload; use crate::executor::error::ExecutorError; use crate::tool::ToolRegistry; use crate::types::event::MessageStatus; +use crate::types::tools::{ToolSearchExecution, ToolSearchStatus}; use crate::utils::common::deserialize_from_value_opt; use crate::utils::uuid7_str; use super::input::{ InputContent, InputFunctionToolCall, InputItem, InputMessage, InputMessageContent, InputTextContent, + deserialize_non_blank_string, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,6 +104,34 @@ pub struct FunctionToolCall { pub status: MessageStatus, } +/// A newly emitted public client tool-search call. +/// +/// Unlike replay input, execution and status have no serde defaults: response +/// translation must populate both fields explicitly. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSearchCall { + #[serde(deserialize_with = "deserialize_tool_search_item_id")] + pub id: String, + #[serde(deserialize_with = "deserialize_non_blank_string")] + pub call_id: String, + pub execution: ToolSearchExecution, + pub arguments: serde_json::Map, + pub status: ToolSearchStatus, +} + +fn deserialize_tool_search_item_id<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let id = deserialize_non_blank_string(deserializer)?; + if id.strip_prefix("tsc_").is_none_or(str::is_empty) { + return Err(serde::de::Error::custom( + "emitted tool_search_call id must use the 'tsc_' prefix with a non-empty suffix", + )); + } + Ok(id) +} + /// A freeform custom tool invocation. /// /// `input` is opaque text and must not be parsed as function-call JSON. @@ -697,6 +727,8 @@ pub enum OutputItem { Message(OutputMessage), #[serde(rename = "function_call")] FunctionCall(FunctionToolCall), + #[serde(rename = "tool_search_call")] + ToolSearchCall(ToolSearchCall), #[serde(rename = "custom_tool_call")] CustomToolCall(CustomToolCall), #[serde(rename = "web_search_call")] @@ -718,7 +750,7 @@ impl OutputItem { Self::FunctionCall(call) => registry .lookup(&call.name) .is_none_or(|entry| !entry.tool_type.is_gateway_owned()), - Self::CustomToolCall(_) => true, + Self::ToolSearchCall(_) | Self::CustomToolCall(_) => true, Self::Message(_) | Self::WebSearchCall(_) | Self::McpCall(_) @@ -734,6 +766,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::ToolSearchCall(call) => Some(InputItem::ToolSearchCall(call.clone().into())), Self::CustomToolCall(call) => Some(InputItem::FunctionCall(call.clone().into())), Self::WebSearchCall(_) | Self::McpCall(_) | Self::McpListTools(_) | Self::Unknown => None, } @@ -745,6 +778,67 @@ mod tests { use super::*; use crate::types::io::InputItem; + #[test] + fn emitted_tool_search_call_is_explicit_and_requires_client_action() { + let wire = serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }); + let item: OutputItem = serde_json::from_value(wire.clone()).expect("valid emitted search call"); + + assert_eq!(serde_json::to_value(&item).expect("call serializes"), wire); + assert!(item.requires_client_action(&ToolRegistry::default())); + let replay = item.to_input_item().expect("search call must remain public on replay"); + assert_eq!(serde_json::to_value(replay).expect("replay serializes"), wire); + } + + #[test] + fn emitted_tool_search_call_rejects_missing_or_invalid_required_fields() { + for missing in ["execution", "status"] { + let mut wire = serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }); + wire.as_object_mut().expect("object").remove(missing); + + assert!( + serde_json::from_value::(wire).is_err(), + "newly emitted calls require explicit {missing}" + ); + } + + for (field, value) in [ + ("id", serde_json::json!(" ")), + ("id", serde_json::json!("fc_1")), + ("id", serde_json::json!("tsc_")), + ("call_id", serde_json::json!(" ")), + ("arguments", serde_json::json!("not an object")), + ] { + let mut wire = serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }); + wire[field] = value; + + assert!( + serde_json::from_value::(wire).is_err(), + "newly emitted calls reject invalid {field}" + ); + } + } + #[test] fn custom_tool_call_preserves_freeform_input_and_requires_client_action() { let item: OutputItem = serde_json::from_value(serde_json::json!({ diff --git a/crates/agentic-server-core/src/types/mod.rs b/crates/agentic-server-core/src/types/mod.rs index 0c60e157..af992511 100644 --- a/crates/agentic-server-core/src/types/mod.rs +++ b/crates/agentic-server-core/src/types/mod.rs @@ -8,11 +8,11 @@ pub use io::{ 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, + InputTokenDetails, InputToolSearchCall, McpCall, McpCallError, McpCallStatus, McpToolExecutionError, + McpToolExecutionErrorContent, OutputItem, OutputMessage, OutputTextContent, OutputTokenDetails, ReasoningOutput, + ReasoningTextContent, ResponseUsage, ResponsesInput, ToolCallOutput, ToolChoice, ToolOutputContent, ToolSearchCall, + ToolSearchOutputMessage, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, + WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, WebSearchSource, }; pub use request_response::{ CompactRequest, CompactedResponse, ContextManagement, IncompleteDetails, RequestPayload, ResponsePayload, @@ -20,6 +20,7 @@ pub use request_response::{ }; pub use tools::{ CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, CustomToolParam, EmptyToolNameError, - FileSearchToolParam, FunctionToolParam, McpToolParam, NonEmptyToolName, ResponsesTool, WebSearchContextSize, - WebSearchFilters, WebSearchToolParam, WebSearchUserLocation, + FileSearchToolParam, FunctionToolParam, McpToolParam, NonEmptyToolName, ResponsesTool, ToolSearchExecution, + ToolSearchStatus, ToolSearchToolParam, WebSearchContextSize, WebSearchFilters, WebSearchToolParam, + WebSearchUserLocation, }; diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 9d2eb2a3..0ac059e5 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::ResponsesTool; +use super::tools::{CodexNamespaceMember, ResponsesTool}; use crate::tool::{CodexNamespaceHandler, CustomHandler, ToolError}; use crate::utils::common::serialize_to_string; @@ -42,6 +42,15 @@ fn default_true() -> bool { true } +/// Structural readiness result returned after validating public tool-search +/// declarations and replay-item wire shapes. The deterministic request-scoped +/// state builder consumes this result and owns ordered-history semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolSearchReadiness { + Inactive, + StatePreparationRequired, +} + #[derive(Debug, Serialize)] pub struct UpstreamRequest<'a> { pub model: &'a str, @@ -106,6 +115,115 @@ where } impl RequestPayload { + /// Whether this request contains public tool-search history, an explicit + /// search declaration, or any declaration whose schema is deferred. + #[must_use] + pub fn contains_tool_search_state(&self) -> bool { + self.input.contains_tool_search_state() + || self + .tools + .as_deref() + .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) + } + + /// Validate the retained public tool-search contract without lowering or + /// executing any declaration. + /// + /// # Errors + /// + /// Returns [`ToolError::Config`] for invalid cardinality, reserved-name or + /// serial-execution conflicts, malformed replay items, and unsupported + /// dynamically returned declarations. + pub(crate) fn tool_search_readiness(&self) -> Result { + self.tool_search_readiness_for_input(&self.input) + } + + pub(crate) fn tool_search_readiness_for_input( + &self, + input: &ResponsesInput, + ) -> Result { + let tools = self.tools.as_deref().unwrap_or_default(); + let declaration_count = tools + .iter() + .filter(|tool| matches!(tool, ResponsesTool::ToolSearch(_))) + .count(); + let input_items = match input { + ResponsesInput::Text(_) => &[][..], + ResponsesInput::Items(items) => items.as_slice(), + }; + let active = input.contains_tool_search_state() || tools.iter().any(tool_activates_tool_search); + if !active { + return Ok(ToolSearchReadiness::Inactive); + } + + if declaration_count > 1 { + return Err(ToolError::Config( + "tool search accepts at most one tool_search declaration".to_owned(), + )); + } + if self.parallel_tool_calls == Some(true) { + return Err(ToolError::Config( + "parallel_tool_calls must be false when tool search is active".to_owned(), + )); + } + + for tool in tools { + tool.validate()?; + if let ResponsesTool::Namespace(namespace) = tool { + validate_tool_search_namespace(namespace)?; + } + if has_reserved_tool_search_name(tool) { + return Err(ToolError::Config( + "model-visible tool name 'tool_search' is reserved while tool search is active".to_owned(), + )); + } + } + + for item in input_items { + match item { + InputItem::ToolSearchCall(call) => { + if call.id.trim().is_empty() { + return Err(ToolError::Config("tool_search_call id must not be blank".to_owned())); + } + if call.call_id.trim().is_empty() { + return Err(ToolError::Config( + "tool_search_call call_id must not be blank".to_owned(), + )); + } + } + InputItem::ToolSearchOutput(output) => { + if output.call_id.trim().is_empty() { + return Err(ToolError::Config( + "tool_search_output call_id must not be blank".to_owned(), + )); + } + for tool in &output.tools { + validate_loaded_tool(tool)?; + } + } + InputItem::Message(_) + | InputItem::FunctionCall(_) + | InputItem::FunctionCallOutput(_) + | InputItem::CustomToolCall(_) + | InputItem::CustomToolCallOutput(_) + | InputItem::Reasoning(_) + | InputItem::Compaction(_) + | InputItem::Unknown => {} + } + } + + Ok(ToolSearchReadiness::StatePreparationRequired) + } + + pub(crate) fn ensure_tool_search_ready(&self) -> Result<(), ToolError> { + match self.tool_search_readiness()? { + ToolSearchReadiness::Inactive => Ok(()), + ToolSearchReadiness::StatePreparationRequired => Err(ToolError::Config( + "tool_search requests require prepared request-scoped state before upstream conversion".to_owned(), + )), + } + } + /// Construct an `UpstreamRequest` suitable for forwarding to vLLM. /// /// Codex `namespace` tools' members are first renamed to their flat, @@ -121,6 +239,7 @@ impl RequestPayload { /// member, or when a custom tool declares a format whose constrained /// decoding cannot be preserved upstream. pub fn to_upstream_request(&self, stream: bool) -> Result, ToolError> { + self.ensure_tool_search_ready()?; let has_built_in_tool = self.declares_built_in_tool(); if has_built_in_tool && self.parallel_tool_calls == Some(true) { return Err(ToolError::Config( @@ -152,11 +271,12 @@ impl RequestPayload { }); let tools = tools.filter(|tools| !tools.is_empty()); let namespace_map = CodexNamespaceHandler.build_namespace_map(self.tools.as_deref())?; + let input = CodexNamespaceHandler.resolve_input(namespace_map.as_ref(), self.input.model_input()); let tool_choice = CodexNamespaceHandler.resolve_tool_choice(namespace_map.as_ref(), self.tool_choice.as_ref()); CustomHandler::validate_tool_choice(self.tools.as_deref(), &tool_choice)?; Ok(UpstreamRequest { model: &self.model, - input: self.input.model_input(), + input, stream, instructions: self.instructions.as_deref(), tools, @@ -179,6 +299,78 @@ impl RequestPayload { } } +fn has_reserved_tool_search_name(tool: &ResponsesTool) -> bool { + match tool { + ResponsesTool::Function(function) => function.name.as_str() == "tool_search", + ResponsesTool::Custom(custom) => custom.name.as_str() == "tool_search", + ResponsesTool::Namespace(namespace) => namespace.name == "tool_search", + ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Unknown => false, + } +} + +fn tool_activates_tool_search(tool: &ResponsesTool) -> bool { + match tool { + ResponsesTool::ToolSearch(_) => true, + ResponsesTool::Function(function) => function.defer_loading == Some(true), + ResponsesTool::Mcp(mcp) => mcp.defer_loading == Some(true), + ResponsesTool::Namespace(namespace) => namespace.tools.iter().any( + |member| matches!(member, CodexNamespaceMember::Function(function) if function.defer_loading == Some(true)), + ), + ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => false, + } +} + +fn validate_loaded_tool(tool: &ResponsesTool) -> Result<(), ToolError> { + match tool { + ResponsesTool::Function(_) | ResponsesTool::Mcp(_) => {} + ResponsesTool::Namespace(namespace) => validate_tool_search_namespace(namespace)?, + ResponsesTool::ToolSearch(_) + | ResponsesTool::Custom(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Unknown => { + return Err(ToolError::Config( + "tool_search_output contains an unsupported tool type".to_owned(), + )); + } + } + tool.validate()?; + if has_reserved_tool_search_name(tool) { + return Err(ToolError::Config( + "loaded model-visible tool name 'tool_search' is reserved".to_owned(), + )); + } + Ok(()) +} + +fn validate_tool_search_namespace(namespace: &crate::types::tools::CodexNamespaceToolParam) -> Result<(), ToolError> { + if namespace.tools.is_empty() { + return Err(ToolError::Config( + "tool-search namespaces must contain at least one function member".to_owned(), + )); + } + if namespace + .tools + .iter() + .any(|member| !matches!(member, CodexNamespaceMember::Function(_))) + { + return Err(ToolError::Config( + "tool-search namespaces may contain only function members".to_owned(), + )); + } + Ok(()) +} + /// Server-side context management configuration for a Responses request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextManagement { @@ -324,6 +516,282 @@ impl From for Vec { mod tests { use super::*; + fn tool_search_declaration() -> Value { + serde_json::json!({ + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}} + } + }) + } + + fn tool_search_request(tools: Vec, input: Value, parallel_tool_calls: Option) -> RequestPayload { + let mut request = serde_json::json!({ + "model": "test", + "parallel_tool_calls": parallel_tool_calls + }); + request["input"] = input; + request["tools"] = Value::Array(tools); + serde_json::from_value(request).expect("request fixture should deserialize") + } + + #[test] + fn tool_search_contract_rejects_request_wide_violations() { + let cases = [ + ( + "multiple declarations", + tool_search_request( + vec![tool_search_declaration(), tool_search_declaration()], + serde_json::json!("hi"), + None, + ), + ), + ( + "parallel calling", + tool_search_request(vec![tool_search_declaration()], serde_json::json!("hi"), Some(true)), + ), + ( + "reserved function name", + tool_search_request( + vec![ + tool_search_declaration(), + serde_json::json!({"type": "function", "name": "tool_search"}), + ], + serde_json::json!("hi"), + None, + ), + ), + ( + "reserved custom name", + tool_search_request( + vec![ + tool_search_declaration(), + serde_json::json!({"type": "custom", "name": "tool_search"}), + ], + serde_json::json!("hi"), + None, + ), + ), + ( + "unsupported returned tool", + tool_search_request( + vec![tool_search_declaration()], + serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [{"type": "custom", "name": "raw"}] + } + ]), + None, + ), + ), + ]; + + for (case, request) in cases { + assert!( + request.to_upstream_request(false).is_err(), + "tool-search request should reject {case}" + ); + } + } + + fn request_with_returned_tools(returned_tools: Vec) -> RequestPayload { + let returned_tools = Value::Array(returned_tools); + tool_search_request( + vec![tool_search_declaration()], + serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": returned_tools + } + ]), + Some(false), + ) + } + + #[test] + fn tool_search_contract_accepts_supported_returned_tool_kinds() { + for returned_tools in [ + vec![], + vec![serde_json::json!({ + "type": "function", + "name": "get_weather", + "parameters": {"type": "object"} + })], + vec![serde_json::json!({ + "type": "namespace", + "name": "weather", + "tools": [{ + "type": "function", + "name": "forecast", + "parameters": {"type": "object"} + }] + })], + vec![serde_json::json!({ + "type": "mcp", + "server_label": "weather", + "server_url": "https://mcp.example.test" + })], + ] { + assert_eq!( + request_with_returned_tools(returned_tools) + .tool_search_readiness() + .expect("supported returned tools pass contract validation"), + ToolSearchReadiness::StatePreparationRequired + ); + } + } + + #[test] + fn tool_search_contract_accepts_declaration_free_manual_public_replay() { + assert_eq!( + tool_search_request( + vec![], + serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + } + ]), + Some(false), + ) + .tool_search_readiness() + .expect("manual public replay is valid without redeclaring tool_search"), + ToolSearchReadiness::StatePreparationRequired + ); + } + + #[test] + fn deferred_declarations_require_tool_search_state_preparation() { + for tool in [ + serde_json::json!({ + "type": "function", + "name": "deferred_function", + "defer_loading": true + }), + serde_json::json!({ + "type": "mcp", + "server_label": "deferred_server", + "server_url": "https://mcp.example.test/mcp", + "defer_loading": true + }), + serde_json::json!({ + "type": "namespace", + "name": "deferred_namespace", + "tools": [{ + "type": "function", + "name": "deferred_member", + "defer_loading": true + }] + }), + ] { + let request = tool_search_request(vec![tool], serde_json::json!("hi"), Some(false)); + assert!(request.contains_tool_search_state()); + assert_eq!( + request + .tool_search_readiness() + .expect("deferred declaration is a valid tool-search trigger"), + ToolSearchReadiness::StatePreparationRequired + ); + assert!(request.to_upstream_request(false).is_err()); + } + } + + #[test] + fn tool_search_contract_rejects_every_unsupported_returned_tool_kind() { + for returned_tool in [ + serde_json::json!({"type": "web_search_preview"}), + serde_json::json!({"type": "file_search", "vector_store_ids": []}), + serde_json::json!({"type": "code_interpreter"}), + serde_json::json!({"type": "custom", "name": "raw"}), + tool_search_declaration(), + serde_json::json!({"type": "future_tool", "opaque": true}), + serde_json::json!({ + "type": "namespace", + "name": "mixed", + "tools": [ + {"type": "function", "name": "valid"}, + {"type": "future_member", "opaque": true} + ] + }), + ] { + assert!( + request_with_returned_tools(vec![returned_tool]) + .tool_search_readiness() + .is_err(), + "unsupported returned tool kind must fail validation" + ); + } + } + + #[test] + fn to_upstream_request_rejects_unprepared_tool_search_state() { + let request = tool_search_request( + vec![tool_search_declaration()], + serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [] + } + ]), + Some(false), + ); + + assert!( + request.to_upstream_request(false).is_err(), + "unprepared tool-search state must not be silently dropped or lowered" + ); + } + + #[test] + fn reserved_tool_search_name_is_allowed_without_tool_search_state() { + let request = tool_search_request( + vec![serde_json::json!({"type": "function", "name": "tool_search"})], + serde_json::json!("hi"), + Some(true), + ); + + let upstream = serde_json::to_value( + request + .to_upstream_request(false) + .expect("reserved name applies only while tool search is active"), + ) + .expect("upstream request serializes"); + assert_eq!(upstream["tools"][0]["name"], "tool_search"); + assert_eq!(upstream["parallel_tool_calls"], true); + } + #[test] fn compact_request_accepts_codex_compatibility_fields() { let request: CompactRequest = serde_json::from_value(serde_json::json!({ diff --git a/crates/agentic-server-core/src/types/tools/mod.rs b/crates/agentic-server-core/src/types/tools/mod.rs index acf36880..a7bf4b76 100644 --- a/crates/agentic-server-core/src/types/tools/mod.rs +++ b/crates/agentic-server-core/src/types/tools/mod.rs @@ -8,5 +8,6 @@ pub mod params; pub use params::{ CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, CustomToolParam, EmptyToolNameError, FileSearchToolParam, FunctionToolParam, McpDiscoveredToolParam, McpToolParam, NonEmptyToolName, ResponsesTool, - WebSearchContextSize, WebSearchFilters, WebSearchToolParam, WebSearchUserLocation, + ToolSearchExecution, ToolSearchStatus, ToolSearchToolParam, WebSearchContextSize, WebSearchFilters, + WebSearchToolParam, WebSearchUserLocation, }; diff --git a/crates/agentic-server-core/src/types/tools/params.rs b/crates/agentic-server-core/src/types/tools/params.rs index b162507d..7b2f7a3c 100644 --- a/crates/agentic-server-core/src/types/tools/params.rs +++ b/crates/agentic-server-core/src/types/tools/params.rs @@ -80,6 +80,8 @@ impl std::fmt::Display for NonEmptyToolName { pub enum ResponsesTool { #[serde(rename = "function")] Function(FunctionToolParam), + #[serde(rename = "tool_search")] + ToolSearch(ToolSearchToolParam), #[serde(rename = "mcp")] Mcp(McpToolParam), #[serde( @@ -143,11 +145,40 @@ pub struct CustomToolParam { pub extra: HashMap, } +/// Only client-executed tool search is part of the public gateway contract. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolSearchExecution { + #[default] + Client, +} + +/// Terminal tool-search calls and outputs are accepted only after completion. +/// Streaming `in_progress` state belongs to event lifecycle payloads rather +/// than this persisted public-item status. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolSearchStatus { + #[default] + Completed, +} + +/// Parameters for a client-executed tool-search declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolSearchToolParam { + pub execution: ToolSearchExecution, + pub description: String, + pub parameters: serde_json::Map, +} + /// Parameters for a gateway MCP built-in tool declaration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpToolParam { pub server_label: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub server_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub connector_id: Option, @@ -159,6 +190,8 @@ pub struct McpToolParam { pub allowed_tools: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub require_approval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defer_loading: Option, /// Request-scoped `tools/list` results used by MCP normalization. This /// field is populated internally and ignored on the public request wire. #[serde( @@ -257,6 +290,7 @@ impl ResponsesTool { pub fn original_type(&self) -> Option<&str> { match self { Self::Function(_) => Some("function"), + Self::ToolSearch(_) => Some("tool_search"), Self::Mcp(_) => Some("mcp"), Self::WebSearch(_) => Some("web_search_preview"), Self::FileSearch(_) => Some("file_search"), @@ -354,7 +388,9 @@ mod tests { let mut tool = serde_json::from_value::(serde_json::json!({ "type": "mcp", "server_label": "repo", - "server_url": "https://mcp.example.test/mcp", + "server_description": "Repository tools", + "server_url": "https://mcp.example.test/mcp?continuation_token=query-secret", + "defer_loading": true, "headers": { "Authorization": "Bearer header-secret", "X-Request-ID": "request-1" @@ -386,11 +422,99 @@ mod tests { assert!(persisted.get("authorization").is_none()); assert!(persisted.get("_agentic_discovered_tools").is_none()); assert_eq!(persisted["server_label"], "repo"); - assert_eq!(persisted["server_url"], "https://mcp.example.test/mcp"); + assert_eq!(persisted["server_description"], "Repository tools"); + assert_eq!( + persisted["server_url"], "https://mcp.example.test/mcp?continuation_token=query-secret", + "persistence preserves the complete endpoint for continuation; model-visible catalogs and public failures must redact it" + ); + assert_eq!(persisted["defer_loading"], true); assert_eq!(persisted["allowed_tools"], serde_json::json!(["read_file"])); assert_eq!(persisted["require_approval"], "never"); } + #[test] + fn responses_tool_search_declaration_round_trips_exactly() { + let declaration = serde_json::json!({ + "type": "tool_search", + "execution": "client", + "description": "Find a tool for the requested task", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }); + + let tool: ResponsesTool = serde_json::from_value(declaration.clone()).expect("valid tool-search declaration"); + + assert_eq!(tool.original_type(), Some("tool_search")); + assert_eq!(tool.tool_type(), Some(crate::tool::ToolType::ToolSearch)); + assert!( + !tool.is_gateway_owned(), + "client-executed tool search must bypass gateway dispatch" + ); + assert!( + tool.to_function_tools().is_empty(), + "client-executed tool search must bypass generic upstream normalization" + ); + assert_eq!(serde_json::to_value(tool).expect("tool serializes"), declaration); + } + + #[test] + fn responses_tool_search_declaration_rejects_invalid_wire_shapes() { + for declaration in [ + serde_json::json!({ + "type": "tool_search", + "description": "Missing execution", + "parameters": {"type": "object"} + }), + serde_json::json!({ + "type": "tool_search", + "execution": "server", + "description": "Hosted execution is excluded", + "parameters": {"type": "object"} + }), + serde_json::json!({ + "type": "tool_search", + "execution": "client", + "description": "Missing parameters" + }), + serde_json::json!({ + "type": "tool_search", + "execution": "client", + "description": "Parameters must be an object", + "parameters": "not an object" + }), + ] { + assert!( + serde_json::from_value::(declaration).is_err(), + "invalid tool-search wire shape must not fall back to an unknown tool" + ); + } + } + + #[test] + fn responses_tool_search_declaration_rejects_bad_description_or_schema() { + for (description, parameters) in [ + (" ", serde_json::json!({"type": "object"})), + ("Find a tool", serde_json::json!({})), + ("Find a tool", serde_json::json!({"type": "array"})), + ] { + let tool: ResponsesTool = serde_json::from_value(serde_json::json!({ + "type": "tool_search", + "execution": "client", + "description": description, + "parameters": parameters + })) + .expect("structurally valid declaration"); + + assert!( + tool.validate().is_err(), + "invalid declaration semantics must fail validation" + ); + } + } + #[test] fn responses_tool_mcp_ignores_unknown_fields() { let tool = serde_json::from_value::(serde_json::json!({ diff --git a/crates/agentic-server-core/tests/support/mod.rs b/crates/agentic-server-core/tests/support/mod.rs index b8c7ae1e..104544d2 100644 --- a/crates/agentic-server-core/tests/support/mod.rs +++ b/crates/agentic-server-core/tests/support/mod.rs @@ -384,6 +384,76 @@ pub fn text_response(text: &str) -> MockResponse { ) } +pub fn function_call_response(id: &str, call_id: &str, name: &str, arguments: &str) -> MockResponse { + MockResponse::Json( + serde_json::json!({ + "id": format!("resp_upstream_{id}"), + "object": "response", + "created_at": 0, + "model": "test-model", + "status": "completed", + "output": [{ + "id": id, + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments, + "status": "completed" + }], + "usage": null, + "incomplete_details": null, + "error": null, + "previous_response_id": null, + "conversation_id": null, + "instructions": null + }) + .to_string(), + ) +} + +pub fn tool_search_function_declarations(name: &str, description: &str) -> Value { + serde_json::json!([ + { + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }, + deferred_function_tool(name, description) + ]) +} + +pub fn deferred_function_tool(name: &str, description: &str) -> Value { + serde_json::json!({ + "type": "function", + "name": name, + "description": description, + "parameters": {"type": "object"}, + "defer_loading": true + }) +} + +pub fn tool_search_output(call_id: &str, name: &str, description: &str) -> Value { + serde_json::json!({ + "type": "tool_search_output", + "call_id": call_id, + "tools": [deferred_function_tool(name, description)] + }) +} + +pub fn tool_search_request( + input: impl Serialize, + tools: Option, + store: bool, + previous_response_id: Option, + conversation_id: Option, +) -> RequestPayload { + let mut request = make_request(input, store, false, previous_response_id, conversation_id); + request.tools = tools.map(|tools| serde_json::from_value(tools).expect("valid tool-search declarations")); + request.parallel_tool_calls = Some(false); + request +} + pub fn request_input_texts(body: &Value) -> Vec { match &body["input"] { Value::String(text) => vec![text.clone()], @@ -470,6 +540,7 @@ pub fn output_text(payload: &ResponsePayload) -> String { .filter_map(|item| match item { OutputItem::Message(msg) => Some(msg.content.iter().map(|c| c.text.as_str()).collect::()), OutputItem::FunctionCall(_) + | OutputItem::ToolSearchCall(_) | OutputItem::CustomToolCall(_) | OutputItem::WebSearchCall(_) | OutputItem::McpCall(_) diff --git a/crates/agentic-server/src/handler/http/responses.rs b/crates/agentic-server/src/handler/http/responses.rs index 02d3d648..d82b0dfd 100644 --- a/crates/agentic-server/src/handler/http/responses.rs +++ b/crates/agentic-server/src/handler/http/responses.rs @@ -57,6 +57,7 @@ pub async fn responses(State(state): State, req: Request) -> Response || payload.previous_response_id.is_some() || payload.conversation_id.is_some() || payload.input.contains_compaction() + || payload.contains_tool_search_state() || payload .context_management .as_ref() diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index 551c6d51..ebd99107 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -14,7 +14,7 @@ use tracing::{debug, warn}; use agentic_core::ResponseUsage; use agentic_core::executor::{ - BoxStream, ExecuteRequest, ExecutorError, RequestContext, persist_turn, rehydrate_conversation, + BoxStream, ExecuteRequest, ExecutorError, RequestContext, persist_turn, rehydrate_for_execution, }; use agentic_core::types::request_response::RequestPayload; use agentic_core::utils::common::utcnow_str; @@ -257,7 +257,7 @@ async fn complete_without_inference( state: &AppState, payload: RequestPayload, ) -> Result<(), WsError> { - let ctx = rehydrate_conversation(payload, &state.exec_ctx).await?; + let ctx = rehydrate_for_execution(payload, &state.exec_ctx).await?; let created_at = utcnow_str(); let created_event = empty_response_event(&ctx, created_at, "response.created", "in_progress", 0, None); let completed_event = empty_response_event( From 447f734c4910d2f064f36afeffb24088fcd24612 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Thu, 20 Aug 2026 13:30:18 +0000 Subject: [PATCH 02/11] Cassette Tests Signed-off-by: haoshan98 --- .../tests/cassettes/README.md | 44 +- .../tests/cassettes/record_cassette.py | 746 ++++- .../cassettes/record_tool_search_cassettes.sh | 404 +++ .../cassettes/test_record_tool_search.py | 923 ++++++ .../tool_search/function_outputs.json | 3 + .../cassettes/tool_search/openai_tools.json | 35 + .../tests/cassettes/tool_search/prompts.txt | 3 + .../cassettes/tool_search/returned_tools.json | 19 + ...Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml | 575 ++++ ...lm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml | 2803 +++++++++++++++++ ...Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml | 391 +++ ...ay-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml | 1869 +++++++++++ ...ay-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml | 1306 ++++++++ ...openai-reference-gpt-5.6-nonstreaming.yaml | 398 +++ ...ch-openai-reference-gpt-5.6-streaming.yaml | 409 +++ .../tool_search/vllm_initial_tools.json | 19 + .../tool_search/vllm_tools_after_search.json | 35 + .../tests/compaction_cassette_test.rs | 213 +- .../stateful_conversation_integration.rs | 204 +- .../tests/stateful_responses_integration.rs | 296 +- .../tests/storage_integration.rs | 217 ++ .../tests/tool_normalization_test.rs | 3 + .../tool_search_characterization_test.rs | 1255 ++++++++ .../tests/tool_search_state_test.rs | 1523 +++++++++ .../tests/tool_search_test.rs | 1822 +++++++++++ crates/agentic-server/tests/responses_test.rs | 570 +++- .../tests/responses_websocket_test.rs | 372 +++ 27 files changed, 16386 insertions(+), 71 deletions(-) create mode 100755 crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh create mode 100644 crates/agentic-server-core/tests/cassettes/test_record_tool_search.py create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json create mode 100644 crates/agentic-server-core/tests/tool_search_characterization_test.rs create mode 100644 crates/agentic-server-core/tests/tool_search_state_test.rs create mode 100644 crates/agentic-server-core/tests/tool_search_test.rs diff --git a/crates/agentic-server-core/tests/cassettes/README.md b/crates/agentic-server-core/tests/cassettes/README.md index 32cf1d2a..2c166b81 100644 --- a/crates/agentic-server-core/tests/cassettes/README.md +++ b/crates/agentic-server-core/tests/cassettes/README.md @@ -45,8 +45,9 @@ The recorder scripts (`record_reasoning_cassettes.sh`, `record_tool_call_cassett ``` --turns N Number of turns --output PATH Output YAML path ---mode MODE responses | conv | isolation | mixed | store_true_then_store_false (default: conv) +--mode MODE responses | messages | conv | isolation | mixed | store_true_then_store_false (default: conv) --stream / --no-stream Streaming or non-streaming (default: streaming) +--transport TRANSPORT http | websocket (default: http; WebSocket requires responses mode) --model NAME Model name sent in requests --no-store Set store=false --vllm URL vLLM upstream, e.g. http://localhost:8000 (responses mode only) @@ -54,6 +55,12 @@ The recorder scripts (`record_reasoning_cassettes.sh`, `record_tool_call_cassett --openai URL OpenAI upstream (default https://api.openai.com) --tools FILE JSON file containing a tools array (responses mode only) --tool-choice VALUE "auto", "none", "required", or JSON e.g. '{"type":"function","name":"foo"}' +--tool-outputs FILE JSON object mapping called tool names to output strings +--tool-search-output-tools FILE + JSON array returned for a client tool-search call +--tools-after-search FILE + Effective tools after normalized direct-vLLM search +--manual-item-replay Replay accumulated items with store=false for direct-vLLM or gateway tool search --input-file FILE JSON string or item array for one HTTP Responses turn --max-output-tokens N max_output_tokens for Responses requests (default 1024; use 0 to omit) --proxy-port PORT Local proxy port (default 7070) @@ -169,6 +176,7 @@ turns: | `record_custom_tool_cassettes.sh` | Matching two-turn custom-tool flows (streaming + non-streaming) | gateway and OpenAI reference | | `record_mcp_cassettes.sh` | Native MCP counter tool discovery and calls (streaming + non-streaming) | gateway and OpenAI reference | | `record_web_search_cassettes.sh` | Matching web-search calls (streaming + non-streaming) | gateway and OpenAI reference | +| `record_tool_search_cassettes.sh` | Three-turn client tool-search characterization; gateway blocking, HTTP/SSE, and WebSocket acceptance | OpenAI reference, direct vLLM, and gateway | ### Text-only (OpenAI) @@ -193,6 +201,40 @@ 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 ``` +### Client tool search (OpenAI reference, direct vLLM, and gateway) + +The recorder captures three turns: search call, linked search output and loaded function call, then linked function +output and final message. OpenAI and gateway use public `tool_search_call`/`tool_search_output`; direct vLLM uses a +private synthetic `tool_search` function. Direct vLLM and gateway blocking use `store: false` full-item replay; +gateway SSE/WebSocket profiles use stored continuation. The private projection is not the gateway-to-vLLM envelope. + +The complete set is exactly seven flows: OpenAI blocking/SSE, direct-vLLM blocking/SSE, and gateway blocking/SSE/WS. +HTTP uses the embedded proxy; WebSocket uses bounded direct capture. Recorded vLLM was `0.25.1`; the target must expose +the Responses API with a compatible function-call parser, but `/version` did not expose exact flags. Use a fresh gateway +database. + +Start the gateway with this SQLite path absent: + +```bash +GATEWAY_PORT=3099 \ +DATABASE_URL=sqlite:///tmp/agentic_api_tool_search_matrix.db \ +V_API_BASE=http://127.0.0.1:8000 \ +V_API_KEY="" \ +V_MODEL=Qwen/Qwen3.6-35B-A3B-FP8 \ +./scripts/codex-start-gateway.sh +``` + +```bash +OPENAI_API_KEY=sk-... \ +TOOL_SEARCH_RECORD_SET=all \ +OPENAI_MODEL=gpt-5.6 \ +VLLM_URL=http://127.0.0.1:8000 \ +MODEL=Qwen/Qwen3.6-35B-A3B-FP8 \ +GATEWAY_URL=http://127.0.0.1:3099 \ +GATEWAY_MODEL=Qwen/Qwen3.6-35B-A3B-FP8 \ +bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +``` + ### Web search (gateway and OpenAI) The default records both providers. Use `WEB_SEARCH_RECORD_SET=gateway` or diff --git a/crates/agentic-server-core/tests/cassettes/record_cassette.py b/crates/agentic-server-core/tests/cassettes/record_cassette.py index ff00d80c..76a98588 100644 --- a/crates/agentic-server-core/tests/cassettes/record_cassette.py +++ b/crates/agentic-server-core/tests/cassettes/record_cassette.py @@ -33,6 +33,7 @@ """ import base64 +import copy import hashlib import json import logging @@ -40,14 +41,17 @@ import secrets import socket import ssl +import stat import struct import sys +import tempfile import threading import time from contextlib import asynccontextmanager +from dataclasses import dataclass from pathlib import Path from typing import Any, AsyncGenerator -from urllib.parse import urlparse +from urllib.parse import parse_qsl, urlparse import click import httpx @@ -55,7 +59,8 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse, StreamingResponse from httpx import AsyncClient -from yaml import dump as yaml_dump, safe_load as yaml_load +from yaml import dump as yaml_dump +from yaml import safe_load as yaml_load logging.basicConfig(level=logging.WARNING) logger = logging.getLogger("cassette_proxy") @@ -64,6 +69,12 @@ PROXY_HOST = "127.0.0.1" PROXY_PORT = 7070 TIMEOUT = 60 * 5 +MAX_WEBSOCKET_HANDSHAKE_BYTES = 64 * 1024 +MAX_WEBSOCKET_FRAME_BYTES = 16 * 1024 * 1024 +MAX_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024 +MAX_WEBSOCKET_MESSAGE_FRAMES = 10_000 +MAX_WEBSOCKET_CAPTURE_BYTES = 64 * 1024 * 1024 +MAX_WEBSOCKET_CAPTURE_MESSAGES = 10_000 EXCLUDED_RESPONSE_HEADERS = { "content-encoding", @@ -80,6 +91,63 @@ "x-run-id", } +SENSITIVE_FIELD_NAMES = { + "access_token", + "api_key", + "apikey", + "auth_token", + "authorization", + "credential", + "cookie", + "client_secret", + "password", + "proxy_authorization", + "refresh_token", + "secret", + "set_cookie", + "sig", + "signature", + "token", + "x_auth_token", + "x_api_key", + "x_amz_credential", + "x_amz_signature", + "x_goog_credential", + "x_goog_signature", +} + +SENSITIVE_ENV_NAME_PARTS = ( + "API_KEY", + "AUTHORIZATION", + "BEARER", + "COOKIE", + "CREDENTIAL", + "PASSWORD", + "PRIVATE_KEY", + "SECRET", + "TOKEN", +) + + +class SecretRecordingError(ValueError): + """Raised before a cassette write could persist sensitive material.""" + + +@dataclass(frozen=True) +class ToolContinuation: + """Input items for one client-tool continuation step.""" + + input_items: list[dict] + loaded_search_tools: bool + + +@dataclass(frozen=True) +class ProxyHandle: + """Owned recorder proxy lifecycle.""" + + server: uvicorn.Server + thread: threading.Thread + def _mask_authorization(value: str) -> str: if not value: @@ -104,6 +172,159 @@ def _filter_response_headers(headers) -> dict: } +def _normalized_sensitive_name(name: object) -> str: + return str(name).strip().lower().replace("-", "_") + + +def _is_masked_secret(value: object) -> bool: + return isinstance(value, str) and value.strip() in {"***", "Bearer ***"} + + +def _has_secret_material(value: object) -> bool: + if value is None or value is False: + return False + if _is_masked_secret(value): + return False + if isinstance(value, str): + return bool(value.strip()) + # Containers are traversed recursively. This keeps JSON Schema properties + # named `password` or `token` recordable while still rejecting actual values. + return not isinstance(value, (list, tuple, set, dict)) + + +def _sensitive_environment_values(environment: dict[str, str]) -> tuple[str, ...]: + values = { + value + for name, value in environment.items() + if value + and len(value) >= 8 + and any(part in name.upper() for part in SENSITIVE_ENV_NAME_PARTS) + and not _is_masked_secret(value) + } + return tuple(sorted(values, key=len, reverse=True)) + + +def _reject_sensitive_url(value: str, path: str) -> None: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https", "ws", "wss"}: + return + if parsed.username or parsed.password: + raise SecretRecordingError(f"refusing to record URL credentials at {path}") + for query_name, query_value in parse_qsl(parsed.query, keep_blank_values=True): + if ( + _normalized_sensitive_name(query_name) in SENSITIVE_FIELD_NAMES + and _has_secret_material(query_value) + ): + raise SecretRecordingError(f"refusing to record URL query credentials at {path}") + + +def _has_nonempty_header_value(value: object) -> bool: + if value is None or value is False: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, tuple, set, dict)): + return bool(value) + return True + + +def _reject_mcp_credentials(value: dict, path: str) -> None: + if value.get("type") != "mcp": + return + headers = value.get("headers") + if isinstance(headers, dict) and any( + _has_nonempty_header_value(header_value) + for header_value in headers.values() + ): + raise SecretRecordingError( + f"refusing to record non-empty MCP headers at {path}.headers" + ) + + server_url = value.get("server_url") + if not isinstance(server_url, str) or not server_url: + return + parsed = urlparse(server_url) + if parsed.username or parsed.password or parsed.query: + raise SecretRecordingError( + f"refusing to record MCP server_url credentials or query at {path}.server_url" + ) + + +def _reject_sensitive_value( + value: object, + *, + path: str, + environment_values: tuple[str, ...], +) -> None: + if isinstance(value, dict): + _reject_mcp_credentials(value, path) + for raw_name, nested in value.items(): + name = _normalized_sensitive_name(raw_name) + nested_path = f"{path}.{raw_name}" if path else str(raw_name) + if name in SENSITIVE_FIELD_NAMES and _has_secret_material(nested): + raise SecretRecordingError(f"refusing to record sensitive field at {nested_path}") + _reject_sensitive_value( + nested, + path=nested_path, + environment_values=environment_values, + ) + return + if isinstance(value, (list, tuple)): + for index, nested in enumerate(value): + _reject_sensitive_value( + nested, + path=f"{path}[{index}]", + environment_values=environment_values, + ) + return + if not isinstance(value, str): + return + + for secret in environment_values: + if secret in value: + raise SecretRecordingError(f"refusing to record an environment secret at {path}") + stripped = value.strip() + if stripped.startswith(("{", "[")): + try: + decoded = json.loads(stripped) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, (dict, list)): + _reject_sensitive_value( + decoded, + path=f"{path}.json", + environment_values=environment_values, + ) + _reject_sensitive_url(value, path) + + +def _prepare_turn_for_write( + turn: dict[str, Any], + *, + environment: dict[str, str] | None = None, +) -> dict[str, Any]: + """Mask envelope authorization headers, then reject all other secrets. + + Request and response bodies remain byte-for-byte semantically intact. Sensitive + values nested in bodies, query parameters, provider errors, or environment-derived + strings fail the recording instead of being silently rewritten. + """ + prepared = copy.deepcopy(turn) + for side in ("request", "response"): + headers = prepared.get(side, {}).get("headers") + if not isinstance(headers, dict): + continue + for name, value in list(headers.items()): + if str(name).lower() == "authorization": + headers[name] = _mask_authorization(str(value)) + + environment_values = _sensitive_environment_values( + dict(os.environ) if environment is None else environment + ) + _reject_sensitive_value(prepared, path="turn", environment_values=environment_values) + return prepared + + def _turn_number(output_file: Path) -> int: if not output_file.exists(): return 1 @@ -116,17 +337,51 @@ def _turn_number(output_file: Path) -> int: return len(data["turns"]) + 1 -def _append_turn(output_file: Path, turn: dict[str, Any]) -> None: - output_file.parent.mkdir(parents=True, exist_ok=True) +def _append_turn( + output_file: Path, + turn: dict[str, Any], + *, + environment: dict[str, str] | None = None, +) -> None: + prepared_turn = _prepare_turn_for_write(turn, environment=environment) + existing_mode = ( + stat.S_IMODE(output_file.stat().st_mode) if output_file.exists() else None + ) if output_file.exists() and output_file.stat().st_size > 0: data = yaml_load(output_file.read_text(encoding="utf-8")) or {} else: data = {} turns: list = data.get("turns", []) - turns.append(turn) + turns.append(prepared_turn) data["turns"] = turns - with open(output_file, "w", encoding="utf-8") as f: - yaml_dump(data, f, allow_unicode=True, default_flow_style=False) + _reject_sensitive_value( + data, + path="cassette", + environment_values=_sensitive_environment_values( + dict(os.environ) if environment is None else environment + ), + ) + + output_file.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=output_file.parent, + prefix=f".{output_file.name}.", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + yaml_dump(data, temporary, allow_unicode=True, default_flow_style=False) + temporary.flush() + os.fsync(temporary.fileno()) + if existing_mode is not None: + os.chmod(temporary_path, existing_mode) + os.replace(temporary_path, output_file) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() @asynccontextmanager @@ -252,7 +507,7 @@ async def _stream() -> AsyncGenerator[str, None]: # ── proxy lifecycle ─────────────────────────────────────────────────────────── -def _start_proxy(output_file: Path, target_host: str, port: int) -> uvicorn.Server: +def _start_proxy(output_file: Path, target_host: str, port: int) -> ProxyHandle: output_file.parent.mkdir(parents=True, exist_ok=True) output_file.write_text("", encoding="utf-8") proxy_app.state.output_file = output_file @@ -264,20 +519,26 @@ def _start_proxy(output_file: Path, target_host: str, port: int) -> uvicorn.Serv thread = threading.Thread(target=server.run, daemon=True) thread.start() - # TCP-only readiness check — no HTTP request forwarded to upstream for _ in range(40): - try: - with socket.create_connection((PROXY_HOST, port), timeout=0.3): - break - except OSError: - time.sleep(0.3) + if server.started and thread.is_alive(): + return ProxyHandle(server=server, thread=thread) + if not thread.is_alive(): + break + time.sleep(0.3) - return server + server.should_exit = True + thread.join(timeout=2) + raise RuntimeError(f"recorder proxy failed to own {PROXY_HOST}:{port}") -def _stop_proxy(server: uvicorn.Server) -> None: - server.should_exit = True - time.sleep(0.5) +def _stop_proxy(handle: ProxyHandle) -> None: + handle.server.should_exit = True + handle.thread.join(timeout=5) + if handle.thread.is_alive(): + handle.server.force_exit = True + handle.thread.join(timeout=2) + if handle.thread.is_alive(): + raise RuntimeError("recorder proxy did not stop within the bounded shutdown window") def _create_conversation(client: httpx.Client, proxy_url: str) -> str: @@ -288,12 +549,16 @@ def _create_conversation(client: httpx.Client, proxy_url: str) -> str: return conv_id -def _send_nonstreaming(client: httpx.Client, body: dict, proxy_url: str) -> dict | None: +def _send_nonstreaming( + client: httpx.Client, + body: dict, + proxy_url: str, +) -> dict | None: resp = client.post(f"{proxy_url}/v1/responses", json=body, timeout=300) - resp.raise_for_status() data = resp.json() + resp.raise_for_status() print(f"\n[Response]\n{json.dumps(data, indent=2)}\n") - return data + return data if isinstance(data, dict) else None def _send_streaming(client: httpx.Client, body: dict, proxy_url: str) -> dict | None: @@ -389,6 +654,7 @@ def __init__(self, url: str, headers: dict[str, str]) -> None: self.url = url self.headers = headers self.sock: socket.socket | ssl.SSLSocket | None = None + self._receive_buffer = bytearray() def __enter__(self) -> "WebSocketClient": parsed = urlparse(self.url) @@ -445,6 +711,10 @@ def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: def _read_exact(self, size: int) -> bytes: assert self.sock is not None chunks = bytearray() + if self._receive_buffer: + buffered = min(size, len(self._receive_buffer)) + chunks.extend(self._receive_buffer[:buffered]) + del self._receive_buffer[:buffered] while len(chunks) < size: chunk = self.sock.recv(size - len(chunks)) if not chunk: @@ -460,7 +730,11 @@ def _read_http_response(self) -> str: if not chunk: raise EOFError("websocket closed during handshake") data.extend(chunk) - return data.decode("iso-8859-1") + if len(data) > MAX_WEBSOCKET_HANDSHAKE_BYTES: + raise ValueError("websocket handshake exceeded the recording limit") + header_end = data.index(b"\r\n\r\n") + 4 + self._receive_buffer.extend(data[header_end:]) + return data[:header_end].decode("iso-8859-1") def send_text(self, text: str) -> None: self._send_frame(0x1, text.encode("utf-8")) @@ -491,7 +765,11 @@ def _send_frame(self, opcode: int, payload: bytes) -> None: def receive_text(self) -> str | None: message = bytearray() + frame_count = 0 while True: + frame_count += 1 + if frame_count > MAX_WEBSOCKET_MESSAGE_FRAMES: + raise ValueError("websocket message exceeded the frame-count recording limit") first, second = self._read_exact(2) fin = bool(first & 0x80) opcode = first & 0x0F @@ -501,6 +779,10 @@ def receive_text(self) -> str | None: length = struct.unpack("!H", self._read_exact(2))[0] elif length == 127: length = struct.unpack("!Q", self._read_exact(8))[0] + if length > MAX_WEBSOCKET_FRAME_BYTES: + raise ValueError("websocket frame exceeded the recording limit") + if len(message) + length > MAX_WEBSOCKET_MESSAGE_BYTES: + raise ValueError("websocket message exceeded the recording limit") mask = self._read_exact(4) if masked else b"" payload = self._read_exact(length) if masked: @@ -576,6 +858,8 @@ def _send_websocket( } response_data = None + captured_bytes = 0 + captured_messages = 0 print("\n[WebSocket response]") with WebSocketClient(websocket_url, headers) as ws: ws.send_text(json.dumps(wire_body, separators=(",", ":"))) @@ -583,6 +867,12 @@ def _send_websocket( message = ws.receive_text() if message is None: break + captured_messages += 1 + captured_bytes += len(message.encode("utf-8")) + if captured_messages > MAX_WEBSOCKET_CAPTURE_MESSAGES: + raise ValueError("websocket capture exceeded the message-count limit") + if captured_bytes > MAX_WEBSOCKET_CAPTURE_BYTES: + raise ValueError("websocket capture exceeded the byte recording limit") print(message) turn["response"]["websocket"].append(message) try: @@ -590,12 +880,16 @@ def _send_websocket( except json.JSONDecodeError: continue turn["response"]["sse"].append( + f"event: {event.get('type', '')}\n" f"data: {json.dumps(event, separators=(',', ':'))}\n" ) event_type = event.get("type") if event_type == "response.completed": response_data = event.get("response") break + if event_type == "response.failed": + response_data = event.get("response") + break if event_type == "error": response_data = event break @@ -654,36 +948,84 @@ def _inject_tools(body: dict, tools: list | None, tool_choice: Any) -> None: def _extract_tool_calls(response_data: dict | None) -> list[dict]: - """Extract client-owned function and custom tool calls from a response.""" + """Extract client-owned tool calls and reject unusable call linkage.""" if not response_data: return [] output = response_data.get("output", []) - return [ + tool_calls = [ item for item in output - if item.get("type") in {"function_call", "custom_tool_call"} + if item.get("type") + in {"function_call", "custom_tool_call", "tool_search_call"} ] + for call in tool_calls: + call_id = call.get("call_id") + if not isinstance(call_id, str) or not call_id.strip(): + raise ValueError( + f"{call.get('type', 'tool')} item has no non-empty call_id" + ) + return tool_calls -def _build_tool_output_input( +def _canonical_tool_search_output(tools: list[dict]) -> str: + return json.dumps( + {"tools": tools}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _build_tool_continuation( tool_calls: list[dict], tool_outputs: dict[str, str], + tool_search_tools: list[dict] | None, user_prompt: str | None, -) -> list[dict]: - """Build tool output items followed by an optional user message. - - Args: - tool_calls: function_call or custom_tool_call items from the previous response. - tool_outputs: mapping of tool name -> fake JSON output string. - user_prompt: the next user message (None for tool-output-only turns). - - Returns: - A list suitable for the `input` field of the next request. - """ +) -> ToolContinuation: + """Project one semantic client-tool transition onto public or normalized wire.""" input_items: list[dict] = [] + loaded_search_tools = False for call in tool_calls: - call_id = call.get("call_id", "") + call_id = call.get("call_id") + if not isinstance(call_id, str) or not call_id.strip(): + raise ValueError( + f"{call.get('type', 'tool')} item has no non-empty call_id" + ) + + call_type = call.get("type") name = call.get("name", "") + is_public_search = call_type == "tool_search_call" + is_normalized_search = call_type == "function_call" and name == "tool_search" + if is_public_search or is_normalized_search: + if tool_search_tools is None: + raise ValueError( + "a tool-search call requires --tool-search-output-tools" + ) + loaded_search_tools = True + if is_public_search: + input_items.append( + { + "type": "tool_search_output", + "call_id": call_id, + "execution": "client", + "status": "completed", + "tools": copy.deepcopy(tool_search_tools), + } + ) + else: + input_items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": _canonical_tool_search_output(tool_search_tools), + } + ) + continue + + if tool_search_tools is not None and name not in tool_outputs: + raise ValueError( + f"loaded function {name!r} requires an explicit output fixture" + ) output = tool_outputs.get( name, json.dumps({"result": f"mock output for {name}"}) ) @@ -691,13 +1033,14 @@ def _build_tool_output_input( { "type": ( "custom_tool_call_output" - if call.get("type") == "custom_tool_call" + if call_type == "custom_tool_call" else "function_call_output" ), "call_id": call_id, "output": output, } ) + if user_prompt: input_items.append( { @@ -706,7 +1049,113 @@ def _build_tool_output_input( "content": user_prompt, } ) - return input_items + return ToolContinuation( + input_items=input_items, + loaded_search_tools=loaded_search_tools, + ) + + +def _is_tool_search_call(call: dict) -> bool: + return call.get("type") == "tool_search_call" or ( + call.get("type") == "function_call" and call.get("name") == "tool_search" + ) + + +def _validate_tool_search_turn_calls( + turn: int, + calls: list[dict], + returned_tools: list[dict], +) -> None: + if turn == 2: + if len(calls) != 1 or not _is_tool_search_call(calls[0]): + raise ValueError( + "tool-search turn one must emit exactly one search call" + ) + call = calls[0] + arguments = call.get("arguments") + if call.get("type") == "tool_search_call": + if call.get("execution") != "client" or call.get("status") != "completed": + raise ValueError( + "public tool-search call must be explicitly client/completed" + ) + if not isinstance(arguments, dict) or not arguments: + raise ValueError( + "public tool-search arguments must be a non-empty object" + ) + query = arguments.get("query") + else: + if call.get("status") != "completed": + raise ValueError( + "normalized tool-search call must be explicitly completed" + ) + if not isinstance(arguments, str): + raise ValueError( + "normalized tool-search arguments must be JSON text" + ) + try: + decoded = json.loads(arguments) + except json.JSONDecodeError as error: + raise ValueError( + "normalized tool-search arguments must be valid JSON" + ) from error + if not isinstance(decoded, dict) or not decoded: + raise ValueError( + "normalized tool-search arguments must be a non-empty object" + ) + query = decoded.get("query") + if not isinstance(query, str) or not query.strip(): + raise ValueError("tool-search arguments must contain a non-empty query") + return + if turn != 3: + return + + returned_function_names = { + tool.get("name") + for tool in returned_tools + if tool.get("type") == "function" and isinstance(tool.get("name"), str) + } + if ( + len(calls) != 1 + or calls[0].get("type") != "function_call" + or calls[0].get("name") not in returned_function_names + ): + raise ValueError( + "tool-search turn two must emit exactly one loaded function call" + ) + if calls[0].get("status") != "completed": + raise ValueError("loaded function call must be explicitly completed") + arguments = calls[0].get("arguments") + if not isinstance(arguments, str): + raise ValueError("loaded function arguments must be JSON text") + try: + decoded = json.loads(arguments) + except json.JSONDecodeError as error: + raise ValueError("loaded function arguments must be valid JSON") from error + if not isinstance(decoded, dict): + raise ValueError("loaded function arguments must decode to an object") + + +def _build_tool_output_input( + tool_calls: list[dict], + tool_outputs: dict[str, str], + user_prompt: str | None, +) -> list[dict]: + """Build tool output items followed by an optional user message. + + Args: + tool_calls: function_call or custom_tool_call items from the previous response. + tool_outputs: mapping of tool name -> fake JSON output string. + user_prompt: the next user message (None for tool-output-only turns). + + Returns: + A list suitable for the `input` field of the next request. + """ + return _build_tool_continuation( + tool_calls, + tool_outputs, + None, + user_prompt, + ).input_items def run_conv( @@ -927,9 +1376,31 @@ def run_responses( tools: list | None = None, tool_choice: Any = None, tool_outputs: dict[str, str] | None = None, + tool_search_output_tools: list[dict] | None = None, + tools_after_search: list | None = None, max_output_tokens: int | None = None, preset_input: str | list | None = None, + manual_item_replay: bool = False, ) -> None: + tool_search_recording = ( + tool_search_output_tools is not None or tools_after_search is not None + ) + if tool_search_recording and turns != 3: + raise click.UsageError( + "tool-search recorder fixtures require three Responses turns" + ) + if manual_item_replay and (not tool_search_recording or store or preset_input is not None): + raise click.UsageError( + "manual item replay requires store=false tool-search recording without preset input" + ) + if tool_search_recording and not store and not manual_item_replay: + raise click.UsageError( + "store=false tool-search recording requires manual item replay" + ) + if tool_search_recording and branches: + raise click.UsageError( + "tool-search recorder fixtures do not support response branching" + ) response_ids: dict[int, str] = {} responses: dict[int, dict] = {} branch_map: dict[int, int] = {} @@ -942,6 +1413,8 @@ def run_responses( previous_response_id: str | None = None last_response: dict | None = None + search_tools_loaded = False + manual_history: list[dict] = [] for turn in range(1, turns + 1): if turn in branch_map: branch_from = branch_map[turn] @@ -960,11 +1433,29 @@ def run_responses( else: prompt = _prompt(f"Turn {turn}/{turns} — enter prompt: ") - # Inject matching function/custom output items before the user message. - pending_calls = _extract_tool_calls(last_response) if tool_outputs else [] - if pending_calls and tool_outputs: - input_value = _build_tool_output_input( - pending_calls, tool_outputs, prompt if prompt else None + # Inject matching client-tool outputs before the user message. + has_output_fixtures = ( + tool_outputs is not None or tool_search_output_tools is not None + ) + pending_calls = ( + _extract_tool_calls(last_response) if has_output_fixtures else [] + ) + if tool_search_recording: + _validate_tool_search_turn_calls( + turn, + pending_calls, + tool_search_output_tools or [], + ) + if pending_calls: + continuation = _build_tool_continuation( + pending_calls, + tool_outputs or {}, + tool_search_output_tools, + prompt if prompt else None, + ) + input_value = continuation.input_items + search_tools_loaded = ( + search_tools_loaded or continuation.loaded_search_tools ) click.echo( f" [injecting {len(pending_calls)} tool output(s) before user message]" @@ -972,12 +1463,31 @@ def run_responses( else: input_value = prompt + if manual_item_replay: + if isinstance(input_value, str): + new_input_items = [ + { + "type": "message", + "role": "user", + "content": input_value, + } + ] + elif isinstance(input_value, list): + new_input_items = copy.deepcopy(input_value) + else: + raise ValueError("manual item replay input must be a string or item array") + manual_history.extend(new_input_items) + input_value = copy.deepcopy(manual_history) + body: dict = {"model": model, "input": input_value, "stream": stream, "store": store} if max_output_tokens is not None: body["max_output_tokens"] = max_output_tokens if previous_response_id and store: body["previous_response_id"] = previous_response_id - _inject_tools(body, tools, tool_choice) + if tool_search_output_tools is not None: + body["parallel_tool_calls"] = False + effective_tools = tools_after_search if search_tools_loaded else tools + _inject_tools(body, effective_tools, tool_choice) response_data = _send( client, body, @@ -989,6 +1499,19 @@ def run_responses( output_file, ) response_id = response_data.get("id") if response_data else None + if manual_item_replay: + response_output = response_data.get("output") if response_data else None + if not isinstance(response_output, list): + raise ValueError( + "manual item replay requires every response to contain an output array" + ) + manual_history.extend(copy.deepcopy(response_output)) + if tool_search_recording and turn == turns: + final_calls = _extract_tool_calls(response_data) + if final_calls: + raise ValueError( + "tool-search final response must not contain client tool calls" + ) previous_response_id = response_id if store else None last_response = response_data if response_id: @@ -1146,6 +1669,28 @@ def run_responses( "When provided, matching function_call_output or custom_tool_call_output items are injected " "between turns (required for OpenAI Responses API).", ) +@click.option( + "--tool-search-output-tools", + "tool_search_output_tools_file", + metavar="FILE", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="JSON array returned by a client tool-search continuation.", +) +@click.option( + "--tools-after-search", + "tools_after_search_file", + metavar="FILE", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="Effective JSON tools array used after search (required for direct vLLM characterization).", +) +@click.option( + "--manual-item-replay", + is_flag=True, + default=False, + help="Replay full accumulated item history with store=false (direct-vLLM or gateway tool-search).", +) @click.option( "--input-file", type=click.Path(exists=True, dir_okay=False), @@ -1175,6 +1720,9 @@ def main( tools_file: str | None, tool_choice_raw: str | None, tool_outputs_file: str | None, + tool_search_output_tools_file: str | None, + tools_after_search_file: str | None, + manual_item_replay: bool, input_file: str | None, max_output_tokens: int, ) -> None: @@ -1190,6 +1738,43 @@ def main( (bf, branch_turn_number[i] if i < len(branch_turn_number) else None) for i, bf in enumerate(branch_from) ] + tool_search_recording = ( + tool_search_output_tools_file is not None + or tools_after_search_file is not None + ) + if tools_after_search_file and not tool_search_output_tools_file: + raise click.UsageError( + "--tools-after-search requires --tool-search-output-tools." + ) + if tool_search_recording: + if mode != "responses": + raise click.UsageError( + "tool-search recorder fixtures require --mode responses." + ) + if turns != 3: + raise click.UsageError( + "tool-search recorder fixtures require exactly --turns 3." + ) + if branches: + raise click.UsageError( + "tool-search recorder fixtures do not support --branch-from." + ) + if no_store and not manual_item_replay: + raise click.UsageError( + "store=false tool-search characterization requires --manual-item-replay." + ) + if manual_item_replay and not no_store: + raise click.UsageError( + "--manual-item-replay requires --no-store." + ) + if input_file: + raise click.UsageError( + "tool-search recorder fixtures do not support --input-file." + ) + if not tools_file or not tool_outputs_file: + raise click.UsageError( + "tool-search recording requires --tools and --tool-outputs." + ) backend_count = sum(bool(url) for url in (openai_url, vllm_url, gateway_url)) if backend_count > 1: raise click.UsageError("--openai, --vllm, and --gateway are mutually exclusive.") @@ -1232,6 +1817,43 @@ def main( raise click.UsageError("--tool-outputs file must contain a JSON object (name -> output string).") click.echo(f"Tool outputs: {list(tool_outputs.keys())}") + tool_search_output_tools: list[dict] | None = None + if tool_search_output_tools_file: + with open(tool_search_output_tools_file, encoding="utf-8") as f: + tool_search_output_tools = json.load(f) + if not isinstance(tool_search_output_tools, list) or not all( + isinstance(tool, dict) for tool in tool_search_output_tools + ): + raise click.UsageError( + "--tool-search-output-tools must contain a JSON array of objects." + ) + if not tool_search_output_tools: + raise click.UsageError( + "--tool-search-output-tools must contain at least one tool." + ) + + tools_after_search: list | None = None + if tools_after_search_file: + with open(tools_after_search_file, encoding="utf-8") as f: + tools_after_search = json.load(f) + if not isinstance(tools_after_search, list): + raise click.UsageError( + "--tools-after-search must contain a JSON array." + ) + + if tool_search_recording and vllm_url and tools_after_search is None: + raise click.UsageError( + "direct vLLM tool-search characterization requires --tools-after-search." + ) + if tool_search_recording and vllm_url and not manual_item_replay: + raise click.UsageError( + "direct vLLM tool-search characterization requires --manual-item-replay." + ) + if tool_search_recording and not (vllm_url or gateway_url) and manual_item_replay: + raise click.UsageError( + "--manual-item-replay is reserved for direct vLLM or gateway tool-search recording." + ) + if gateway_url: target = gateway_url.rstrip("/") headers = {} @@ -1250,6 +1872,8 @@ def main( headers = {"Authorization": f"Bearer {api_key}"} backend_label = f"OpenAI: {target}" + _reject_sensitive_url(target, "upstream URL") + output_file = Path(output).resolve() proxy_url = f"http://{PROXY_HOST}:{proxy_port}" store = not no_store @@ -1281,15 +1905,18 @@ def main( target, headers, output_file, - tools, - tool_choice, - tool_outputs, - response_max_output_tokens, - preset_input, + tools=tools, + tool_choice=tool_choice, + tool_outputs=tool_outputs, + tool_search_output_tools=tool_search_output_tools, + tools_after_search=tools_after_search, + max_output_tokens=response_max_output_tokens, + preset_input=preset_input, + manual_item_replay=manual_item_replay, ) else: click.echo(f"Proxy: {proxy_url} (requests go through here for recording)") - server = _start_proxy(output_file, target, proxy_port) + proxy = _start_proxy(output_file, target, proxy_port) click.echo(f"Proxy ready on {proxy_url}\n") try: @@ -1313,11 +1940,14 @@ def main( target, headers, output_file, - tools, - tool_choice, - tool_outputs, - response_max_output_tokens, - preset_input, + tools=tools, + tool_choice=tool_choice, + tool_outputs=tool_outputs, + tool_search_output_tools=tool_search_output_tools, + tools_after_search=tools_after_search, + max_output_tokens=response_max_output_tokens, + preset_input=preset_input, + manual_item_replay=manual_item_replay, ) elif mode == "messages": run_messages( @@ -1334,7 +1964,7 @@ def main( elif mode == "store_true_then_store_false": run_store_true_then_store_false(client, turns, model, stream, proxy_url) finally: - _stop_proxy(server) + _stop_proxy(proxy) click.echo(f"\nAll turns recorded -> {output_file}") diff --git a/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh b/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh new file mode 100755 index 00000000..3985b624 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Records client tool-search characterization against OpenAI, direct vLLM, or +# the gateway blocking, HTTP/SSE, or WebSocket profiles. +# +# Usage from the repository root: +# TOOL_SEARCH_RECORD_SET=openai-reference OPENAI_API_KEY=sk-... \ +# bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +# TOOL_SEARCH_RECORD_SET=direct-vllm VLLM_URL=http://localhost:8000 \ +# bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +# TOOL_SEARCH_RECORD_SET=gateway-nonstreaming GATEWAY_URL=http://localhost:9000 \ +# bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +# TOOL_SEARCH_RECORD_SET=gateway-streaming GATEWAY_URL=http://localhost:9000 \ +# bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +# TOOL_SEARCH_RECORD_SET=gateway-websocket GATEWAY_URL=http://localhost:9000 \ +# bash crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh + +set -euo pipefail + +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE_DIR="$SCRIPTS_DIR/tool_search" +RETURNED_TOOLS="$BASE_DIR/returned_tools.json" +FUNCTION_OUTPUTS="$BASE_DIR/function_outputs.json" +PROMPTS="$BASE_DIR/prompts.txt" +OPENAI_TOOLS="$BASE_DIR/openai_tools.json" +VLLM_INITIAL_TOOLS="$BASE_DIR/vllm_initial_tools.json" +VLLM_NEXT_TOOLS="$BASE_DIR/vllm_tools_after_search.json" +OPENAI_MODEL="${OPENAI_MODEL:-gpt-5.6}" +VLLM_MODEL="${MODEL:-Qwen/Qwen3.6-35B-A3B-FP8}" +VLLM_URL="${VLLM_URL:-}" +GATEWAY_MODEL="${GATEWAY_MODEL:-${MODEL:-Qwen/Qwen3.6-35B-A3B-FP8}}" +GATEWAY_URL="${GATEWAY_URL:-}" +TOOL_SEARCH_RECORD_SET="${TOOL_SEARCH_RECORD_SET:-all}" + +model_slug() { + printf '%s' "$1" | tr '/: ' '---' +} + +validate_recording() { + local cassette="$1" + local projection="$2" + local initial_tools="$3" + local next_tools="$4" + + python - "$cassette" "$projection" "$RETURNED_TOOLS" "$initial_tools" "$next_tools" <<'PY' +import json +import sys +from pathlib import Path + +import yaml + +path = Path(sys.argv[1]) +projection = sys.argv[2] +expected_returned_tools = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) +expected_initial_tools = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8")) +expected_next_tools = json.loads(Path(sys.argv[5]).read_text(encoding="utf-8")) if sys.argv[5] else None +document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} +turns = document.get("turns") or [] +if len(turns) != 3: + raise SystemExit(f"ERROR: expected three recorded turns in {path}, found {len(turns)}") + + +def terminal_response(turn): + response = turn.get("response") or {} + websocket = (turn.get("request") or {}).get("transport") == "websocket" + expected_status = 101 if websocket else 200 + if response.get("status_code") != expected_status: + raise SystemExit(f"ERROR: recording returned HTTP {response.get('status_code')}: {response.get('body')}") + if isinstance(response.get("body"), dict): + return response["body"] + for raw in response.get("sse") or []: + for line in raw.splitlines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + event = json.loads(line.removeprefix("data: ")) + if event.get("type") == "response.completed": + return event.get("response") or {} + if event.get("type") in {"error", "response.failed"}: + raise SystemExit(f"ERROR: streaming recording failed: {event}") + raise SystemExit("ERROR: streaming recording has no response.completed event") + + +responses = [terminal_response(turn) for turn in turns] +outputs = [response.get("output") or [] for response in responses] +search_type = "function_call" if projection == "normalized" else "tool_search_call" +first_calls = [item for item in outputs[0] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] +search_calls = [ + item + for item in outputs[0] + if item.get("type") == search_type + and (projection != "normalized" or item.get("name") == "tool_search") +] +if len(first_calls) != 1 or len(search_calls) != 1: + raise SystemExit(f"ERROR: expected one {projection} search call, found {search_calls}") +search_arguments = search_calls[0].get("arguments") +if projection != "normalized": + if search_calls[0].get("execution") != "client" or search_calls[0].get("status") != "completed": + raise SystemExit(f"ERROR: public search call must be explicitly client/completed: {search_calls[0]}") + if not isinstance(search_arguments, dict) or not search_arguments.get("query"): + raise SystemExit(f"ERROR: public search arguments must be a non-empty query object: {search_arguments}") +else: + if search_calls[0].get("status") != "completed": + raise SystemExit(f"ERROR: normalized search call must be explicitly completed: {search_calls[0]}") + try: + normalized_arguments = json.loads(search_arguments) + except (TypeError, json.JSONDecodeError) as error: + raise SystemExit(f"ERROR: normalized search arguments are invalid: {search_arguments}") from error + if not isinstance(normalized_arguments, dict) or not normalized_arguments.get("query"): + raise SystemExit(f"ERROR: normalized search arguments must contain a query: {normalized_arguments}") + +first_stream = (turns[0].get("response") or {}).get("sse") or [] +if projection != "normalized" and first_stream: + events = [] + for raw in first_stream: + for line in raw.splitlines(): + if line.startswith("data: ") and line != "data: [DONE]": + events.append(json.loads(line.removeprefix("data: "))) + sequence_numbers = [event.get("sequence_number") for event in events] + if sequence_numbers != list(range(len(events))): + raise SystemExit(f"ERROR: public stream sequence numbers are not contiguous: {sequence_numbers}") + if any(event.get("type") in {"response.function_call_arguments.delta", "response.function_call_arguments.done"} for event in events): + raise SystemExit("ERROR: public search stream leaked normalized function argument events") + if any( + event.get("type") in {"response.output_item.added", "response.output_item.done"} + and (event.get("item") or {}).get("type") == "function_call" + and (event.get("item") or {}).get("name") == "tool_search" + for event in events + ): + raise SystemExit("ERROR: public search stream leaked a normalized synthetic function item") + if any( + tool.get("type") == "function" and tool.get("name") == "tool_search" + for event in events + for tool in ((event.get("response") or {}).get("tools") or []) + ): + raise SystemExit("ERROR: public search stream leaked the private synthetic declaration") + lifecycle = [ + event + for event in events + if event.get("type") in {"response.output_item.added", "response.output_item.done"} + and (event.get("item") or {}).get("type") == "tool_search_call" + ] + if [event.get("type") for event in lifecycle] != ["response.output_item.added", "response.output_item.done"]: + raise SystemExit(f"ERROR: public search lifecycle is incomplete or reordered: {lifecycle}") + added = lifecycle[0].get("item") or {} + done = lifecycle[1].get("item") or {} + if added.get("status") != "in_progress" or added.get("arguments") != {}: + raise SystemExit(f"ERROR: invalid public search added item: {added}") + if done.get("status") != "completed" or done.get("arguments") != search_arguments: + raise SystemExit(f"ERROR: invalid public search done item: {done}") + if ( + added.get("id") != done.get("id") + or added.get("call_id") != done.get("call_id") + or lifecycle[0].get("output_index") != lifecycle[1].get("output_index") + ): + raise SystemExit("ERROR: public search lifecycle changed item/call identity") + if done not in outputs[0]: + raise SystemExit("ERROR: terminal response output differs from public search done item") + +second_calls = [item for item in outputs[1] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] +loaded_calls = [ + item + for item in outputs[1] + if item.get("type") == "function_call" and item.get("name") == "get_weather" +] +if len(second_calls) != 1 or len(loaded_calls) != 1: + raise SystemExit(f"ERROR: expected one loaded get_weather call, found {loaded_calls}") +if loaded_calls[0].get("status") != "completed": + raise SystemExit(f"ERROR: loaded get_weather call must be explicitly completed: {loaded_calls[0]}") +try: + loaded_arguments = json.loads(loaded_calls[0].get("arguments") or "null") +except json.JSONDecodeError as error: + raise SystemExit("ERROR: loaded function arguments are not valid JSON") from error +if loaded_arguments != {"city": "Paris"}: + raise SystemExit(f"ERROR: loaded function arguments did not equal city=Paris: {loaded_arguments}") + +turn_two_input = (turns[1].get("request") or {}).get("body", {}).get("input") or [] +turn_three_input = (turns[2].get("request") or {}).get("body", {}).get("input") or [] +search_output_type = "function_call_output" if projection == "normalized" else "tool_search_output" +search_outputs = [ + item + for item in turn_two_input + if item.get("type") == search_output_type and item.get("call_id") == search_calls[0].get("call_id") +] +function_outputs = [ + item + for item in turn_three_input + if item.get("type") == "function_call_output" and item.get("call_id") == loaded_calls[0].get("call_id") +] +if len(search_outputs) != 1 or len(function_outputs) != 1: + raise SystemExit("ERROR: recorded continuation call IDs do not link to their preceding calls") +if projection != "normalized": + if ( + search_outputs[0].get("type") != "tool_search_output" + or search_outputs[0].get("execution") != "client" + or search_outputs[0].get("status") != "completed" + or search_outputs[0].get("tools") != expected_returned_tools + ): + raise SystemExit(f"ERROR: invalid public search output: {search_outputs[0]}") +else: + if search_outputs[0].get("type") != "function_call_output": + raise SystemExit(f"ERROR: invalid normalized search output: {search_outputs[0]}") + raw_normalized_output = search_outputs[0].get("output") or "" + normalized_output = json.loads(raw_normalized_output or "null") + expected_canonical_output = json.dumps( + {"tools": expected_returned_tools}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + if raw_normalized_output != expected_canonical_output: + raise SystemExit("ERROR: normalized search output is not canonical") + if normalized_output != {"tools": expected_returned_tools}: + raise SystemExit(f"ERROR: normalized search output has no tools: {normalized_output}") +if function_outputs[0].get("type") != "function_call_output": + raise SystemExit(f"ERROR: invalid loaded function output: {function_outputs[0]}") + +messages = [item for item in outputs[2] if item.get("type") == "message"] +final_calls = [item for item in outputs[2] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] +if final_calls: + raise SystemExit(f"ERROR: final response contained tool calls: {final_calls}") +text = "".join( + part.get("text", "") + for message in messages + for part in message.get("content") or [] + if part.get("type") == "output_text" +) +if text.strip() != "PARIS_WEATHER_OK": + raise SystemExit(f"ERROR: final response text did not match: {text!r}") + +request_bodies = [(turn.get("request") or {}).get("body") or {} for turn in turns] +if request_bodies[0].get("tools") != expected_initial_tools: + raise SystemExit("ERROR: first turn tools differ from the initial fixture") +if projection == "public-stored": + if not all(body.get("store") is True for body in request_bodies): + raise SystemExit("ERROR: public characterization must use stored continuation") + if request_bodies[1].get("previous_response_id") != responses[0].get("id"): + raise SystemExit("ERROR: public turn two does not continue turn one") + if request_bodies[2].get("previous_response_id") != responses[1].get("id"): + raise SystemExit("ERROR: public turn three does not continue turn two") + if "tools" in request_bodies[1] or "tools" in request_bodies[2]: + raise SystemExit("ERROR: public continuation must omit top-level tools after search") +else: + if not all(body.get("store") is False for body in request_bodies): + raise SystemExit("ERROR: manual tool-search replay must use store=false") + if any("previous_response_id" in body for body in request_bodies): + raise SystemExit("ERROR: manual tool-search replay must omit previous_response_id") + if projection == "normalized": + if request_bodies[1].get("tools") != expected_next_tools or request_bodies[2].get("tools") != expected_next_tools: + raise SystemExit("ERROR: direct-vLLM continuation did not retain post-search tools") + elif "tools" in request_bodies[1] or "tools" in request_bodies[2]: + raise SystemExit("ERROR: gateway manual replay must omit top-level tools after search") + first_input = request_bodies[0].get("input") or [] + second_input = request_bodies[1].get("input") or [] + third_input = request_bodies[2].get("input") or [] + expected_second_prefix = first_input + outputs[0] + expected_third_prefix = second_input + outputs[1] + if second_input[: len(expected_second_prefix)] != expected_second_prefix: + raise SystemExit("ERROR: manual turn two does not replay full turn-one item history") + if third_input[: len(expected_third_prefix)] != expected_third_prefix: + raise SystemExit("ERROR: manual turn three does not replay full prior item history") +PY +} + +record_scenario() { + local endpoint_flag="$1" + local endpoint="$2" + local model="$3" + local tools="$4" + local next_tools="$5" + local projection="$6" + local output="$7" + local recorder_args=("${@:8}") + local temporary_output + local next_tools_args=() + local continuation_args=() + + temporary_output="$(mktemp "$BASE_DIR/.tool-search-cassette.XXXXXX")" + if [[ -n "$next_tools" ]]; then + next_tools_args=(--tools-after-search "$next_tools") + fi + if [[ "$projection" == "normalized" || "$projection" == "gateway-public" ]]; then + continuation_args=(--no-store --manual-item-replay) + fi + + if ! python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 3 \ + "${recorder_args[@]}" \ + --model "$model" \ + "$endpoint_flag" "$endpoint" \ + --tools "$tools" \ + --tool-choice auto \ + --tool-outputs "$FUNCTION_OUTPUTS" \ + --tool-search-output-tools "$RETURNED_TOOLS" \ + "${next_tools_args[@]}" \ + "${continuation_args[@]}" \ + --max-output-tokens 4096 \ + --output "$temporary_output" < "$PROMPTS" + then + rm -f -- "$temporary_output" + return 1 + fi + + if ! validate_recording "$temporary_output" "$projection" "$tools" "$next_tools"; then + rm -f -- "$temporary_output" + return 1 + fi + chmod 664 "$temporary_output" + mv -- "$temporary_output" "$output" + printf 'recorded %s\n' "$output" +} + +record_provider() { + local provider="$1" + local endpoint_flag="$2" + local endpoint="$3" + local model="$4" + local tools="$5" + local next_tools="$6" + local projection="$7" + local prefix="$8" + local slug + + slug="$(model_slug "$model")" + printf 'Recording %s blocking tool-search characterization\n' "$provider" + record_scenario \ + "$endpoint_flag" "$endpoint" "$model" "$tools" "$next_tools" "$projection" \ + "$BASE_DIR/${prefix}-${slug}-nonstreaming.yaml" --no-stream + printf 'Recording %s streaming tool-search characterization\n' "$provider" + record_scenario \ + "$endpoint_flag" "$endpoint" "$model" "$tools" "$next_tools" "$projection" \ + "$BASE_DIR/${prefix}-${slug}-streaming.yaml" --stream +} + +case "$TOOL_SEARCH_RECORD_SET" in + openai-reference|openai|direct-vllm|vllm|gateway-nonstreaming|gateway-streaming|gateway-websocket|gateway|all) ;; + *) + printf 'ERROR: TOOL_SEARCH_RECORD_SET must be openai-reference, direct-vllm, gateway-nonstreaming, gateway-streaming, gateway-websocket, gateway, or all\n' >&2 + exit 1 + ;; +esac + +for required_file in \ + "$RETURNED_TOOLS" \ + "$FUNCTION_OUTPUTS" \ + "$PROMPTS" \ + "$OPENAI_TOOLS" \ + "$VLLM_INITIAL_TOOLS" \ + "$VLLM_NEXT_TOOLS" +do + if [[ ! -f "$required_file" ]]; then + printf 'ERROR: required fixture does not exist: %s\n' "$required_file" >&2 + exit 1 + fi +done + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(openai-reference|openai|all)$ ]] && [[ -z "${OPENAI_API_KEY:-}" ]]; then + printf 'ERROR: OPENAI_API_KEY is required for %s\n' "$TOOL_SEARCH_RECORD_SET" >&2 + exit 1 +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(direct-vllm|vllm|all)$ ]] && [[ -z "$VLLM_URL" ]]; then + printf 'ERROR: VLLM_URL is required for %s\n' "$TOOL_SEARCH_RECORD_SET" >&2 + exit 1 +fi +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-nonstreaming|gateway-streaming|gateway-websocket|gateway|all)$ ]] && [[ -z "$GATEWAY_URL" ]]; then + printf 'ERROR: GATEWAY_URL is required for %s\n' "$TOOL_SEARCH_RECORD_SET" >&2 + exit 1 +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(openai-reference|openai|all)$ ]]; then + record_provider \ + OpenAI --openai https://api.openai.com "$OPENAI_MODEL" \ + "$OPENAI_TOOLS" "" public-stored tool-search-openai-reference +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-nonstreaming|gateway|all)$ ]]; then + slug="$(model_slug "$GATEWAY_MODEL")" + printf 'Recording gateway blocking tool-search flow\n' + record_scenario \ + --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" gateway-public \ + "$BASE_DIR/tool-search-gateway-${slug}-nonstreaming.yaml" --no-stream +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-streaming|gateway|all)$ ]]; then + slug="$(model_slug "$GATEWAY_MODEL")" + printf 'Recording gateway HTTP/SSE tool-search flow\n' + record_scenario \ + --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" public-stored \ + "$BASE_DIR/tool-search-gateway-${slug}-streaming.yaml" --stream +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-websocket|gateway|all)$ ]]; then + slug="$(model_slug "$GATEWAY_MODEL")" + printf 'Recording gateway WebSocket tool-search flow\n' + record_scenario \ + --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" public-stored \ + "$BASE_DIR/tool-search-gateway-${slug}-websocket.yaml" --stream --transport websocket +fi + +if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(direct-vllm|vllm|all)$ ]]; then + record_provider \ + direct-vLLM --vllm "$VLLM_URL" "$VLLM_MODEL" \ + "$VLLM_INITIAL_TOOLS" "$VLLM_NEXT_TOOLS" normalized tool-search-direct-vllm +fi diff --git a/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py b/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py new file mode 100644 index 00000000..362a8320 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py @@ -0,0 +1,923 @@ +"""Focused offline tests for client tool-search cassette recording.""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from click.testing import CliRunner + +import record_cassette + + +RETURNED_TOOLS = [ + { + "type": "function", + "name": "get_weather", + "description": "Get the weather for a city.", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "strict": True, + "defer_loading": True, + } +] + + +class RecordToolSearchTests(unittest.TestCase): + def test_proxy_start_requires_an_owned_listener_before_request_execution(self) -> None: + class FailedServer: + started = False + should_exit = False + force_exit = False + + def run(self) -> None: + return None + + failed_server = FailedServer() + with tempfile.TemporaryDirectory() as directory: + capture = Path(directory) / "capture.yaml" + with ( + mock.patch.object(record_cassette.uvicorn, "Server", return_value=failed_server), + mock.patch.object(record_cassette, "run_responses") as run_responses, + ): + result = CliRunner().invoke( + record_cassette.main, + [ + "--mode", "responses", + "--turns", "1", + "--gateway", "http://gateway.test", + "--model", "test-model", + "--no-stream", + "--output", str(capture), + ], + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("failed to own", str(result.exception)) + self.assertTrue(failed_server.should_exit) + run_responses.assert_not_called() + + def test_gateway_cli_profile_accepts_public_store_false_manual_replay(self) -> None: + with tempfile.TemporaryDirectory() as directory: + directory_path = Path(directory) + tools = directory_path / "tools.json" + outputs = directory_path / "outputs.json" + returned = directory_path / "returned.json" + capture = directory_path / "capture.yaml" + tools.write_text(json.dumps([{"type": "tool_search", "execution": "client"}]), encoding="utf-8") + outputs.write_text(json.dumps({"get_weather": "sunny"}), encoding="utf-8") + returned.write_text(json.dumps(RETURNED_TOOLS), encoding="utf-8") + + with ( + mock.patch.object(record_cassette, "_start_proxy", return_value=object()), + mock.patch.object(record_cassette, "_stop_proxy"), + mock.patch.object(record_cassette, "run_responses") as run_responses, + ): + result = CliRunner().invoke( + record_cassette.main, + [ + "--mode", "responses", + "--turns", "3", + "--gateway", "http://gateway.test", + "--model", "test-model", + "--no-stream", + "--no-store", + "--manual-item-replay", + "--tools", str(tools), + "--tool-outputs", str(outputs), + "--tool-search-output-tools", str(returned), + "--output", str(capture), + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIs(run_responses.call_args.kwargs["manual_item_replay"], True) + self.assertIs(run_responses.call_args.args[4], False, "gateway profile must set store=false") + + def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + directory_path = Path(directory) + tools = directory_path / "tools.json" + outputs = directory_path / "outputs.json" + returned = directory_path / "returned.json" + capture = directory_path / "capture.yaml" + tools.write_text(json.dumps([{"type": "tool_search", "execution": "client"}]), encoding="utf-8") + outputs.write_text(json.dumps({"get_weather": "sunny"}), encoding="utf-8") + returned.write_text(json.dumps(RETURNED_TOOLS), encoding="utf-8") + + with ( + mock.patch.object(record_cassette, "_start_proxy") as start_proxy, + mock.patch.object(record_cassette, "run_responses") as run_responses, + ): + result = CliRunner().invoke( + record_cassette.main, + [ + "--mode", "responses", + "--turns", "3", + "--gateway", "http://gateway.test", + "--transport", "websocket", + "--model", "test-model", + "--stream", + "--tools", str(tools), + "--tool-outputs", str(outputs), + "--tool-search-output-tools", str(returned), + "--output", str(capture), + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + start_proxy.assert_not_called() + self.assertIs(run_responses.call_args.args[4], True) + self.assertEqual(run_responses.call_args.args[7], "websocket") + + def test_websocket_handshake_preserves_coalesced_first_frame(self) -> None: + class Socket: + def __init__(self) -> None: + self.chunks = [b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n\x81\x02ok"] + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) if self.chunks else b"" + + client = record_cassette.WebSocketClient("ws://gateway.test", {}) + client.sock = Socket() + + response = client._read_http_response() + + self.assertTrue(response.endswith("\r\n\r\n")) + self.assertEqual(client.receive_text(), "ok") + + def test_websocket_rejects_oversized_frames_before_reading_payload(self) -> None: + class Socket: + def __init__(self) -> None: + self.chunks = [b"\x81\x7f", (5).to_bytes(8, "big")] + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) if self.chunks else b"" + + client = record_cassette.WebSocketClient("ws://gateway.test", {}) + client.sock = Socket() + + with ( + mock.patch.object(record_cassette, "MAX_WEBSOCKET_FRAME_BYTES", 4), + self.assertRaisesRegex(ValueError, "frame exceeded"), + ): + client.receive_text() + + def test_websocket_rejects_oversized_fragmented_messages(self) -> None: + class Socket: + def __init__(self) -> None: + self.chunks = [b"\x01\x03", b"abc", b"\x80\x02", b"de"] + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) if self.chunks else b"" + + socket = Socket() + client = record_cassette.WebSocketClient("ws://gateway.test", {}) + client.sock = socket + + with ( + mock.patch.object(record_cassette, "MAX_WEBSOCKET_MESSAGE_BYTES", 4), + self.assertRaisesRegex(ValueError, "message exceeded"), + ): + client.receive_text() + + self.assertEqual(socket.chunks, [b"de"], "oversized payload must not be read") + + def test_websocket_recording_rejects_an_oversized_capture(self) -> None: + class Socket: + def __enter__(self) -> "Socket": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def send_text(self, _text: str) -> None: + return None + + def receive_text(self) -> str: + return "12345" + + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "capture.yaml" + with ( + mock.patch.object(record_cassette, "WebSocketClient", return_value=Socket()), + mock.patch.object(record_cassette, "MAX_WEBSOCKET_CAPTURE_BYTES", 4), + mock.patch.object(record_cassette, "_append_turn") as append_turn, + self.assertRaisesRegex(ValueError, "byte recording limit"), + ): + record_cassette._send_websocket( + {"model": "test", "input": "hello"}, + "http://gateway.test", + {}, + output, + ) + + append_turn.assert_not_called() + + def test_websocket_recording_stops_on_response_failed(self) -> None: + failed = { + "type": "response.failed", + "response": { + "id": "resp_failed", + "status": "failed", + "error": {"code": "provider_failure", "message": "stopped"}, + }, + } + + class Socket: + def __init__(self) -> None: + self.messages = [json.dumps(failed), None] + + def __enter__(self) -> "Socket": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def send_text(self, _text: str) -> None: + return None + + def receive_text(self) -> str | None: + return self.messages.pop(0) + + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "capture.yaml" + with ( + mock.patch.object(record_cassette, "WebSocketClient", return_value=Socket()), + mock.patch.object(record_cassette, "_append_turn") as append_turn, + ): + response = record_cassette._send_websocket( + {"model": "test", "input": "hello"}, + "http://gateway.test", + {}, + output, + ) + + self.assertEqual(response, failed["response"]) + turn = append_turn.call_args.args[1] + self.assertEqual(turn["response"]["status_code"], 101) + self.assertEqual(json.loads(turn["response"]["websocket"][0]), failed) + self.assertTrue(turn["response"]["sse"][0].startswith("event: response.failed\n")) + + def test_public_returned_fixture_preserves_deferral_but_vllm_next_tools_clear_it(self) -> None: + fixture_directory = Path(__file__).with_name("tool_search") + returned = json.loads((fixture_directory / "returned_tools.json").read_text(encoding="utf-8")) + vllm_next = json.loads( + (fixture_directory / "vllm_tools_after_search.json").read_text(encoding="utf-8") + ) + self.assertIs(returned[0]["defer_loading"], True) + loaded = next(tool for tool in vllm_next if tool.get("name") == "get_weather") + self.assertNotIn("defer_loading", loaded) + + def test_public_search_then_function_outputs_share_one_continuation_builder(self) -> None: + search_calls = record_cassette._extract_tool_calls( + { + "output": [ + { + "id": "tsc_public", + "type": "tool_search_call", + "call_id": "call_search_public", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather tool"}, + } + ] + } + ) + + search_continuation = record_cassette._build_tool_continuation( + search_calls, + {"get_weather": '{"temperature_c":21}'}, + RETURNED_TOOLS, + None, + ) + + self.assertTrue(search_continuation.loaded_search_tools) + self.assertEqual( + search_continuation.input_items, + [ + { + "type": "tool_search_output", + "call_id": "call_search_public", + "execution": "client", + "status": "completed", + "tools": RETURNED_TOOLS, + } + ], + ) + + function_calls = record_cassette._extract_tool_calls( + { + "output": [ + { + "id": "fc_weather", + "type": "function_call", + "call_id": "call_weather", + "name": "get_weather", + "arguments": '{"city":"Paris"}', + } + ] + } + ) + function_continuation = record_cassette._build_tool_continuation( + function_calls, + {"get_weather": '{"temperature_c":21}'}, + RETURNED_TOOLS, + None, + ) + + self.assertFalse(function_continuation.loaded_search_tools) + self.assertEqual( + function_continuation.input_items, + [ + { + "type": "function_call_output", + "call_id": "call_weather", + "output": '{"temperature_c":21}', + } + ], + ) + + def test_normalized_search_uses_canonical_function_output_projection(self) -> None: + calls = record_cassette._extract_tool_calls( + { + "output": [ + { + "id": "fc_search", + "type": "function_call", + "call_id": "call_search_normalized", + "name": "tool_search", + "arguments": '{"query":"weather tool"}', + } + ] + } + ) + + continuation = record_cassette._build_tool_continuation( + calls, + {"get_weather": '{"temperature_c":21}'}, + RETURNED_TOOLS, + None, + ) + + self.assertTrue(continuation.loaded_search_tools) + self.assertEqual(continuation.input_items[0]["type"], "function_call_output") + self.assertEqual(continuation.input_items[0]["call_id"], "call_search_normalized") + expected = json.dumps( + {"tools": RETURNED_TOOLS}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + self.assertEqual(continuation.input_items[0]["output"], expected) + self.assertEqual(json.loads(continuation.input_items[0]["output"]), {"tools": RETURNED_TOOLS}) + + def test_tool_continuations_reject_empty_ids_and_missing_search_tools(self) -> None: + for call in ( + {"type": "tool_search_call", "call_id": ""}, + {"type": "function_call", "name": "tool_search"}, + {"type": "function_call", "name": "get_weather", "call_id": " "}, + ): + with self.subTest(call=call), self.assertRaises(ValueError): + record_cassette._extract_tool_calls({"output": [call]}) + + with self.assertRaises(ValueError): + record_cassette._build_tool_continuation( + [ + { + "type": "tool_search_call", + "call_id": "call_without_tools", + } + ], + {}, + None, + None, + ) + with self.assertRaisesRegex(ValueError, "explicit output fixture"): + record_cassette._build_tool_continuation( + [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_without_output", + } + ], + {}, + RETURNED_TOOLS, + None, + ) + + def test_existing_outputs_and_central_secret_validation(self) -> None: + continuation = record_cassette._build_tool_continuation( + [ + { + "type": "function_call", + "call_id": "call_function", + "name": "lookup", + }, + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "raw_echo", + }, + ], + {"lookup": "function result", "raw_echo": "custom result"}, + None, + "continue", + ) + self.assertEqual( + [item["type"] for item in continuation.input_items], + ["function_call_output", "custom_tool_call_output", "message"], + ) + + safe_turn = { + "request": { + "headers": {"authorization": "Bearer live-key"}, + "query_params": {}, + "body": { + "input": "hello", + "parameters": { + "type": "object", + "properties": {"password": {"type": "string"}}, + }, + }, + }, + "response": {"headers": {}, "body": {"output": []}}, + } + prepared = record_cassette._prepare_turn_for_write(safe_turn, environment={}) + self.assertEqual(prepared["request"]["headers"]["authorization"], "Bearer ***") + self.assertEqual(safe_turn["request"]["headers"]["authorization"], "Bearer live-key") + + unsafe_turns = ( + { + "request": { + "headers": {}, + "query_params": {}, + "body": {"tools": [{"headers": {"x-api-key": "nested-secret"}}]}, + }, + "response": {}, + }, + { + "request": { + "headers": {}, + "query_params": {"api_key": "query-secret"}, + "body": {}, + }, + "response": {}, + }, + { + "request": {"headers": {}, "query_params": {}, "body": {}}, + "response": {"body": {"error": {"message": "failed with sk-live-secret"}}}, + }, + { + "request": { + "headers": {}, + "query_params": {}, + "body": { + "tools": [ + { + "type": "mcp", + "server_url": "https://mcp.example.test/run", + "headers": {"X-Tenant": "tenant-secret"}, + } + ] + }, + }, + "response": {}, + }, + { + "request": { + "headers": {}, + "query_params": {}, + "body": { + "tools": [ + { + "type": "mcp", + "server_url": "https://user@mcp.example.test/run?tenant=private", + } + ] + }, + }, + "response": {}, + }, + { + "request": { + "headers": {}, + "query_params": {}, + "body": { + "image_url": "https://files.example.test/object?X-Amz-Credential=credential&X-Amz-Signature=signed" + }, + }, + "response": {}, + }, + { + "request": {"headers": {}, "query_params": {}, "body": {}}, + "response": { + "body": { + "output": '{"tools":[{"type":"mcp","headers":{"X-Tenant":"nested-secret"}}]}' + } + }, + }, + ) + environments = ( + {}, + {}, + {"OPENAI_API_KEY": "sk-live-secret"}, + {}, + {}, + {}, + {}, + ) + for unsafe_turn, environment in zip(unsafe_turns, environments, strict=True): + with self.subTest(turn=unsafe_turn), self.assertRaises( + record_cassette.SecretRecordingError + ): + record_cassette._prepare_turn_for_write(unsafe_turn, environment=environment) + + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "capture.yaml" + with self.assertRaises(record_cassette.SecretRecordingError): + record_cassette._append_turn( + output, + unsafe_turns[0], + environment={}, + ) + self.assertFalse(output.exists()) + + def test_append_turn_preserves_existing_mode_and_cleans_temporary_file(self) -> None: + turn = { + "request": {"headers": {}, "query_params": {}, "body": {"input": "safe"}}, + "response": {"headers": {}, "body": {"output": []}}, + } + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "capture.yaml" + output.write_text("turns: []\n", encoding="utf-8") + output.chmod(0o664) + + record_cassette._append_turn(output, turn, environment={}) + + self.assertEqual(output.stat().st_mode & 0o777, 0o664) + self.assertEqual(list(output.parent.glob(f".{output.name}.*")), []) + + new_output = Path(directory) / "new-capture.yaml" + record_cassette._append_turn(new_output, turn, environment={}) + self.assertEqual(new_output.stat().st_mode & 0o077, 0) + self.assertEqual(list(new_output.parent.glob(f".{new_output.name}.*")), []) + + def test_linear_responses_flow_switches_to_next_tools_and_rejects_branches(self) -> None: + initial_tools = [{"type": "function", "name": "tool_search"}] + next_tools = initial_tools + RETURNED_TOOLS + responses = [ + { + "id": "resp_search", + "output": [ + { + "type": "function_call", + "name": "tool_search", + "call_id": "call_search", + "status": "completed", + "arguments": '{"query":"weather tool"}', + } + ], + }, + { + "id": "resp_function", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_weather", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + ], + }, + {"id": "resp_final", "output": [{"type": "message"}]}, + ] + sent_bodies: list[dict] = [] + + def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> dict: + sent_bodies.append(body) + return responses[len(sent_bodies) - 1] + + with ( + mock.patch.object( + record_cassette, + "_prompt", + side_effect=["find a weather tool", "call it", "finish"], + ), + mock.patch.object(record_cassette, "_send", side_effect=fake_send), + ): + record_cassette.run_responses( + client=object(), + turns=3, + model="test-model", + stream=False, + store=False, + branches=[], + proxy_url="http://unused", + tools=initial_tools, + tool_outputs={"get_weather": '{"temperature_c":21}'}, + tool_search_output_tools=RETURNED_TOOLS, + tools_after_search=next_tools, + manual_item_replay=True, + ) + + self.assertEqual([body["tools"] for body in sent_bodies], [initial_tools, next_tools, next_tools]) + self.assertTrue(all(body["store"] is False for body in sent_bodies)) + self.assertTrue(all("previous_response_id" not in body for body in sent_bodies)) + + turn_one_input = sent_bodies[0]["input"] + turn_two_input = sent_bodies[1]["input"] + turn_three_input = sent_bodies[2]["input"] + self.assertEqual( + turn_one_input, + [{"type": "message", "role": "user", "content": "find a weather tool"}], + ) + self.assertEqual(turn_two_input[: len(turn_one_input)], turn_one_input) + self.assertEqual(turn_two_input[1]["type"], "function_call") + self.assertEqual(turn_two_input[1]["call_id"], "call_search") + self.assertEqual(turn_two_input[2]["type"], "function_call_output") + self.assertEqual(turn_two_input[2]["call_id"], "call_search") + self.assertEqual(turn_three_input[: len(turn_two_input)], turn_two_input) + loaded_call = next( + item + for item in turn_three_input[len(turn_two_input) :] + if item.get("type") == "function_call" + ) + loaded_output = next( + item + for item in turn_three_input[len(turn_two_input) :] + if item.get("type") == "function_call_output" + ) + self.assertEqual(loaded_call["call_id"], "call_weather") + self.assertEqual(loaded_output["call_id"], "call_weather") + self.assertTrue(all(body["parallel_tool_calls"] is False for body in sent_bodies)) + + with self.assertRaisesRegex(record_cassette.click.UsageError, "branching"): + record_cassette.run_responses( + client=object(), + turns=3, + model="test-model", + stream=False, + store=True, + branches=[(1, 2)], + proxy_url="http://unused", + tools=initial_tools, + tool_outputs={"get_weather": "result"}, + tool_search_output_tools=RETURNED_TOOLS, + ) + + def test_public_linear_responses_flow_keeps_public_top_level_tools(self) -> None: + public_tools = [ + {"type": "tool_search", "execution": "client"}, + { + "type": "function", + "name": "get_weather", + "defer_loading": True, + }, + ] + responses = [ + { + "id": "resp_search", + "output": [ + { + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather tool"}, + } + ], + }, + { + "id": "resp_function", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_weather", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + ], + }, + {"id": "resp_final", "output": [{"type": "message"}]}, + ] + sent_bodies: list[dict] = [] + + def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> dict: + sent_bodies.append(body) + return responses[len(sent_bodies) - 1] + + with ( + mock.patch.object( + record_cassette, + "_prompt", + side_effect=["find a weather tool", "call it", "finish"], + ), + mock.patch.object(record_cassette, "_send", side_effect=fake_send), + ): + record_cassette.run_responses( + client=object(), + turns=3, + model="test-model", + stream=False, + store=True, + branches=[], + proxy_url="http://unused", + tools=public_tools, + tool_outputs={"get_weather": '{"temperature_c":21}'}, + tool_search_output_tools=RETURNED_TOOLS, + ) + + self.assertEqual(sent_bodies[0]["tools"], public_tools) + self.assertNotIn("tools", sent_bodies[1]) + self.assertNotIn("tools", sent_bodies[2]) + public_output = sent_bodies[1]["input"][0] + self.assertEqual(public_output["type"], "tool_search_output") + self.assertEqual(public_output["call_id"], "call_search") + self.assertEqual(public_output["execution"], "client") + self.assertEqual(public_output["status"], "completed") + self.assertEqual(public_output["tools"], RETURNED_TOOLS) + self.assertEqual(sent_bodies[2]["input"][0]["type"], "function_call_output") + self.assertTrue(all(body["parallel_tool_calls"] is False for body in sent_bodies)) + + def test_gateway_public_manual_replay_is_store_false_and_omits_tools_after_search(self) -> None: + public_tools = [ + {"type": "tool_search", "execution": "client"}, + {"type": "function", "name": "get_weather", "defer_loading": True}, + ] + responses = [ + { + "id": "resp_search", + "output": [{ + "type": "tool_search_call", + "id": "tsc_search", + "call_id": "call_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather"}, + }], + }, + { + "id": "resp_function", + "output": [{ + "type": "function_call", + "id": "fc_weather", + "name": "get_weather", + "call_id": "call_weather", + "status": "completed", + "arguments": '{"city":"Paris"}', + }], + }, + {"id": "resp_final", "output": [{"type": "message"}]}, + ] + sent_bodies: list[dict] = [] + + def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> dict: + sent_bodies.append(body) + return responses[len(sent_bodies) - 1] + + with ( + mock.patch.object(record_cassette, "_prompt", side_effect=["find", "call", "finish"]), + mock.patch.object(record_cassette, "_send", side_effect=fake_send), + ): + record_cassette.run_responses( + client=object(), + turns=3, + model="test-model", + stream=False, + store=False, + branches=[], + proxy_url="http://unused", + tools=public_tools, + tool_outputs={"get_weather": "sunny"}, + tool_search_output_tools=RETURNED_TOOLS, + manual_item_replay=True, + ) + + self.assertTrue(all(body["store"] is False for body in sent_bodies)) + self.assertTrue(all("previous_response_id" not in body for body in sent_bodies)) + self.assertEqual(sent_bodies[0]["tools"], public_tools) + self.assertNotIn("tools", sent_bodies[1]) + self.assertNotIn("tools", sent_bodies[2]) + self.assertEqual(sent_bodies[1]["input"][1]["type"], "tool_search_call") + self.assertEqual(sent_bodies[1]["input"][2]["type"], "tool_search_output") + self.assertEqual(sent_bodies[2]["input"][3]["type"], "message") + self.assertEqual(sent_bodies[2]["input"][4]["type"], "function_call") + self.assertEqual(sent_bodies[2]["input"][5]["type"], "function_call_output") + + def test_turn_validation_requires_search_and_object_loaded_arguments(self) -> None: + valid_public = { + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather tool"}, + } + valid_normalized = { + "type": "function_call", + "name": "tool_search", + "call_id": "call_search", + "status": "completed", + "arguments": '{"query":"weather tool"}', + } + valid_loaded = { + "type": "function_call", + "name": "get_weather", + "call_id": "call_weather", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + record_cassette._validate_tool_search_turn_calls(2, [valid_public], RETURNED_TOOLS) + record_cassette._validate_tool_search_turn_calls(2, [valid_normalized], RETURNED_TOOLS) + record_cassette._validate_tool_search_turn_calls(3, [valid_loaded], RETURNED_TOOLS) + record_cassette._validate_tool_search_turn_calls( + 3, + [{**valid_loaded, "arguments": '{"city":"London"}'}], + RETURNED_TOOLS, + ) + + invalid_calls = ( + (2, {key: value for key, value in valid_public.items() if key != "execution"}), + (2, {**valid_public, "execution": "server"}), + (2, {**valid_public, "status": "in_progress"}), + (2, {**valid_public, "arguments": '{"query":"weather tool"}'}), + (2, {key: value for key, value in valid_normalized.items() if key != "status"}), + (2, {**valid_normalized, "status": "in_progress"}), + (2, {**valid_normalized, "arguments": "{}"}), + (2, {**valid_normalized, "arguments": "not-json"}), + (3, {key: value for key, value in valid_loaded.items() if key != "status"}), + (3, {**valid_loaded, "status": "in_progress"}), + (3, {**valid_loaded, "arguments": "[]"}), + ) + for turn, call in invalid_calls: + with self.subTest(turn=turn, call=call), self.assertRaises(ValueError): + record_cassette._validate_tool_search_turn_calls(turn, [call], RETURNED_TOOLS) + + def test_linear_flow_rejects_a_final_tool_call(self) -> None: + responses = [ + { + "id": "resp_search", + "output": [ + { + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather tool"}, + } + ], + }, + { + "id": "resp_function", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_weather", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + ], + }, + { + "id": "resp_bad_final", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_again", + "arguments": '{"city":"Paris"}', + } + ], + }, + ] + + with ( + mock.patch.object(record_cassette, "_prompt", side_effect=["search", "call", "finish"]), + mock.patch.object(record_cassette, "_send", side_effect=responses), + self.assertRaisesRegex(ValueError, "final response"), + ): + record_cassette.run_responses( + client=object(), + turns=3, + model="test-model", + stream=False, + store=True, + branches=[], + proxy_url="http://unused", + tools=[{"type": "tool_search"}], + tool_outputs={"get_weather": "weather"}, + tool_search_output_tools=RETURNED_TOOLS, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json b/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json new file mode 100644 index 00000000..0301a7b5 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json @@ -0,0 +1,3 @@ +{ + "get_weather": "{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}" +} diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json new file mode 100644 index 00000000..150e5482 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json @@ -0,0 +1,35 @@ +[ + { + "type": "tool_search", + "execution": "client", + "description": "Search the client tool catalog for a tool that can satisfy the request.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "A concise description of the needed capability." + } + }, + "required": ["query"], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt b/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt new file mode 100644 index 00000000..db3697ed --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt @@ -0,0 +1,3 @@ +First call tool_search exactly once to find a weather tool. Do not call get_weather yet. +Now call get_weather exactly once with {"city":"Paris"}. Do not call tool_search again. +Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK. diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json new file mode 100644 index 00000000..cfe12c80 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json @@ -0,0 +1,19 @@ +[ + { + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml new file mode 100644 index 00000000..f0061072 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml @@ -0,0 +1,575 @@ +turns: +- filename: t1 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + created_at: 1787143221 + frequency_penalty: 0.0 + id: resp_89e5972151968df7 + incomplete_details: null + input_messages: null + instructions: null + kv_transfer_params: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: "The user wants me to call `tool_search` exactly once to find a weather\ + \ tool.\nI should use the `tool_search` function with a query like \"\ + weather\" or \"current weather\".\nThe description says: \"Get the current\ + \ weather for a city.\"\nI will call `tool_search` with query \"weather\ + \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ + Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ + query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ + \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ + \ let's verify the exact parameters. `tool_search` requires `query`.\n\ + I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ + \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ + weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ + The prompt says: \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ + \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ + Output matches.\n" + type: reasoning_text + encrypted_content: null + id: rs_86d01db313ae56e6 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "weather tool"}' + call_id: chatcmpl-tool-aca6cd11d99339b4 + caller: null + id: fc_9ee5ffe95a32e0b8 + name: tool_search + namespace: null + status: completed + type: function_call + output_messages: null + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: null + prompt: null + reasoning: null + service_tier: auto + status: completed + temperature: 1.0 + text: null + tool_choice: auto + tools: + - allowed_callers: null + defer_loading: null + description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + output_schema: null + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + top_logprobs: null + top_p: 0.95 + truncation: disabled + usage: + input_tokens: 348 + input_tokens_details: + cached_tokens: 0 + cached_tokens_per_turn: [] + input_tokens_per_turn: [] + output_tokens: 311 + output_tokens_details: + output_tokens_per_turn: [] + reasoning_tokens: 0 + tool_output_tokens: 0 + tool_output_tokens_per_turn: [] + total_tokens: 659 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once to find a weather\ + \ tool.\nI should use the `tool_search` function with a query like \"\ + weather\" or \"current weather\".\nThe description says: \"Get the current\ + \ weather for a city.\"\nI will call `tool_search` with query \"weather\ + \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ + Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ + query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ + \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ + \ let's verify the exact parameters. `tool_search` requires `query`.\n\ + I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ + \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ + weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ + The prompt says: \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ + \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ + Output matches.\n" + type: reasoning_text + encrypted_content: null + id: rs_86d01db313ae56e6 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "weather tool"}' + call_id: chatcmpl-tool-aca6cd11d99339b4 + caller: null + id: fc_9ee5ffe95a32e0b8 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-aca6cd11d99339b4 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + created_at: 1787143223 + frequency_penalty: 0.0 + id: resp_afbd31d42786a8a2 + incomplete_details: null + input_messages: null + instructions: null + kv_transfer_params: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to call `get_weather` with the city "Paris". + + The instruction specifies "exactly once" and "Do not call tool_search + again". + + I have found the tool `get_weather` in the previous step. + + I need to construct the function call with the parameter `{"city": "Paris"}`. + + The tool signature is `get_weather(city: string)`. + + The parameter matches. + + I will call `get_weather(city="Paris")`. + + No tool_search call will be made. + + Done. + + ' + type: reasoning_text + encrypted_content: null + id: rs_93c3775bd42c986b + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-8479880974982f23 + caller: null + id: fc_87049b418f2fe733 + name: get_weather + namespace: null + status: completed + type: function_call + output_messages: null + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: null + prompt: null + reasoning: null + service_tier: auto + status: completed + temperature: 1.0 + text: null + tool_choice: auto + tools: + - allowed_callers: null + defer_loading: null + description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + output_schema: null + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - allowed_callers: null + defer_loading: null + description: Get the current weather for a city. + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + top_logprobs: null + top_p: 0.95 + truncation: disabled + usage: + input_tokens: 562 + input_tokens_details: + cached_tokens: 0 + cached_tokens_per_turn: [] + input_tokens_per_turn: [] + output_tokens: 140 + output_tokens_details: + output_tokens_per_turn: [] + reasoning_tokens: 0 + tool_output_tokens: 0 + tool_output_tokens_per_turn: [] + total_tokens: 702 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once to find a weather\ + \ tool.\nI should use the `tool_search` function with a query like \"\ + weather\" or \"current weather\".\nThe description says: \"Get the current\ + \ weather for a city.\"\nI will call `tool_search` with query \"weather\ + \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ + Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ + query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ + \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ + \ let's verify the exact parameters. `tool_search` requires `query`.\n\ + I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ + \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ + weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ + The prompt says: \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ + \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ + Output matches.\n" + type: reasoning_text + encrypted_content: null + id: rs_86d01db313ae56e6 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "weather tool"}' + call_id: chatcmpl-tool-aca6cd11d99339b4 + caller: null + id: fc_9ee5ffe95a32e0b8 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-aca6cd11d99339b4 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + - content: + - text: 'The user wants me to call `get_weather` with the city "Paris". + + The instruction specifies "exactly once" and "Do not call tool_search + again". + + I have found the tool `get_weather` in the previous step. + + I need to construct the function call with the parameter `{"city": "Paris"}`. + + The tool signature is `get_weather(city: string)`. + + The parameter matches. + + I will call `get_weather(city="Paris")`. + + No tool_search call will be made. + + Done. + + ' + type: reasoning_text + encrypted_content: null + id: rs_93c3775bd42c986b + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-8479880974982f23 + caller: null + id: fc_87049b418f2fe733 + name: get_weather + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-8479880974982f23 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + created_at: 1787143224 + frequency_penalty: 0.0 + id: resp_b8ebf173c78f5ec6 + incomplete_details: null + input_messages: null + instructions: null + kv_transfer_params: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to call no more tools and reply with a specific + string based on the previous tool output. + + I already called `get_weather` and got the result `{"city":"Paris","condition":"clear","temperature_c":21}`. + + The user''s instruction is: "Use the function output and call no more + tools. Reply with exactly PARIS_WEATHER_OK." + + So I will just output "PARIS_WEATHER_OK". + + ' + type: reasoning_text + encrypted_content: null + id: rs_b6140caa04ab505a + status: null + summary: [] + type: reasoning + - content: + - annotations: [] + logprobs: null + text: ' + + + PARIS_WEATHER_OK' + type: output_text + id: msg_bf2b600626377798 + phase: null + role: assistant + status: completed + type: message + output_messages: null + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: null + prompt: null + reasoning: null + service_tier: auto + status: completed + temperature: 1.0 + text: null + tool_choice: auto + tools: + - allowed_callers: null + defer_loading: null + description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + output_schema: null + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - allowed_callers: null + defer_loading: null + description: Get the current weather for a city. + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + top_logprobs: null + top_p: 0.95 + truncation: disabled + usage: + input_tokens: 639 + input_tokens_details: + cached_tokens: 0 + cached_tokens_per_turn: [] + input_tokens_per_turn: [] + output_tokens: 99 + output_tokens_details: + output_tokens_per_turn: [] + reasoning_tokens: 0 + tool_output_tokens: 0 + tool_output_tokens_per_turn: [] + total_tokens: 738 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml new file mode 100644 index 00000000..955a2e51 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml @@ -0,0 +1,2803 @@ +turns: +- filename: t1 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: true + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + 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: + - 'event: response.created + + ' + - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"abb83b715b53c8ad","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"The","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" user wants me","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` exactly once","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to find a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" weather tool.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nI","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" should not call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `get_weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` yet.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nThe","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" description","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" for `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_search` says","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": \"Search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the client tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" catalog. Available","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" catalog entry:","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" get_weather —","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Get the current","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" weather for a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" city.\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"I need","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to provide","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" a query for","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool search.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nQuery:","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"find","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" a weather tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"I will call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` with this","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" query.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Wait","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":", the prompt","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" says \"First","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exactly once to","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" find a weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Do not call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" get_weather yet","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\"\nSo","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" I just","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nLet''s","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" do it","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nQuery","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": \"weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Parameters","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": `{\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"query\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"}`\nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nChecking","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" strict","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"ness","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": `","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"allowed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_callers`","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is null,","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `defer_loading","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` is null","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". Only","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_search` is","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" available.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Proceed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nOutput","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" matches the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" format","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nI","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" will generate the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool call.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \nNote","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": I","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" should","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" just output","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the tool call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\n```","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"json\n{","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"name\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"tool_search\",","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"parameters\": {","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"query\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n }","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n}\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"```\nActually","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":", the prompt","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" says \"First","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exactly once to","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" find a weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool.\" I","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"''ll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" just make","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the call.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nDone.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \nWait","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":", let''s","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" verify the schema","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":":","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_search` takes","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `query`","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" (string).","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nAll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" good.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Proceed.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \nOutput matches","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \n(Self","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"-Correction","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"/Ref","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"inement during thought","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":")\nShould","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" I be","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" more specific in","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the query?","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"find a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is fine.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nI will","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" generate","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":140,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nProceed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":141,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"ing. \n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":142,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"[Output Generation","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":143,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"]\ntool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":144,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_search(query=\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":145,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"weather tool\")","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":146,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":147,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \n(Note","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":148,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": I''ll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":149,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" format it correctly","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":150,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" as a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":151,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool call)","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":152,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":153,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":154,"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nI + should not call `get_weather` yet.\nThe description for `tool_search` says: + \"Search the client tool catalog. Available catalog entry: get_weather — Get + the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: + \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the + prompt says \"First call tool_search exactly once to find a weather tool. Do + not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: + \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking + strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` + is available.\nProceed. \nOutput matches the format.\nI will generate the tool + call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": + {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First + call tool_search exactly once to find a weather tool.\" I''ll just make the + call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll + good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during + thought)\nShould I be more specific in the query? \"find a weather tool\" is + fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather + tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"response.reasoning_text.done"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"part":{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nI + should not call `get_weather` yet.\nThe description for `tool_search` says: + \"Search the client tool catalog. Available catalog entry: get_weather — Get + the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: + \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the + prompt says \"First call tool_search exactly once to find a weather tool. Do + not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: + \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking + strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` + is available.\nProceed. \nOutput matches the format.\nI will generate the tool + call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": + {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First + call tool_search exactly once to find a weather tool.\" I''ll just make the + call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll + good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during + thought)\nShould I be more specific in the query? \"find a weather tool\" is + fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather + tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"},"sequence_number":155,"type":"response.reasoning_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"abb83b715b53c8ad","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nI + should not call `get_weather` yet.\nThe description for `tool_search` says: + \"Search the client tool catalog. Available catalog entry: get_weather — Get + the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: + \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the + prompt says \"First call tool_search exactly once to find a weather tool. Do + not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: + \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking + strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` + is available.\nProceed. \nOutput matches the format.\nI will generate the tool + call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": + {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First + call tool_search exactly once to find a weather tool.\" I''ll just make the + call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll + good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during + thought)\nShould I be more specific in the query? \"find a weather tool\" is + fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather + tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":156,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"arguments":"","call_id":"call_85ac6429f2576051","name":"tool_search","type":"function_call","id":"b388db0be9780d3a","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":157,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"{\"query\": \"","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":158,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"weather tool","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":159,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"\"}","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":160,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"arguments":"{\"query\": \"weather tool\"}","item_id":"b388db0be9780d3a","name":"tool_search","output_index":1,"sequence_number":161,"type":"response.function_call_arguments.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"arguments":"{\"query\": \"weather tool\"}","call_id":"call_85ac6429f2576051","name":"tool_search","type":"function_call","id":"b388db0be9780d3a","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":162,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_9327d10a4a99e129","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nI + should not call `get_weather` yet.\nThe description for `tool_search` says: + \"Search the client tool catalog. Available catalog entry: get_weather — Get + the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: + \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the + prompt says \"First call tool_search exactly once to find a weather tool. Do + not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: + \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking + strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` + is available.\nProceed. \nOutput matches the format.\nI will generate the tool + call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": + {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First + call tool_search exactly once to find a weather tool.\" I''ll just make the + call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll + good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during + thought)\nShould I be more specific in the query? \"find a weather tool\" is + fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather + tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"query\": + \"weather tool\"}","call_id":"chatcmpl-tool-a8fc33ab402e4ff0","name":"tool_search","type":"function_call","id":"fc_857408fee70af6a5","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":348,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":415,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":763},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":163,"type":"response.completed"} + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once to find a weather\ + \ tool.\nI should not call `get_weather` yet.\nThe description for `tool_search`\ + \ says: \"Search the client tool catalog. Available catalog entry: get_weather\ + \ — Get the current weather for a city.\"\nI need to provide a query for\ + \ the tool search.\nQuery: \"find a weather tool\"\nI will call `tool_search`\ + \ with this query.\nWait, the prompt says \"First call tool_search exactly\ + \ once to find a weather tool. Do not call get_weather yet.\"\nSo I just\ + \ call `tool_search`.\nLet's do it.\nQuery: \"weather tool\"\nParameters:\ + \ `{\"query\": \"weather tool\"}`\nDone. \nChecking strictness: `allowed_callers`\ + \ is null, `defer_loading` is null. Only `tool_search` is available.\n\ + Proceed. \nOutput matches the format.\nI will generate the tool call.\ + \ \nNote: I should just output the tool call.\n```json\n{\n \"name\"\ + : \"tool_search\",\n \"parameters\": {\n \"query\": \"weather tool\"\ + \n }\n}\n```\nActually, the prompt says \"First call tool_search exactly\ + \ once to find a weather tool.\" I'll just make the call.\nDone. \nWait,\ + \ let's verify the schema: `tool_search` takes `query` (string).\nAll\ + \ good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement\ + \ during thought)\nShould I be more specific in the query? \"find a weather\ + \ tool\" is fine.\nI will generate the call. \nDone. \nProceeding. \n\ + [Output Generation]\ntool_search(query=\"weather tool\")\nDone. \n(Note:\ + \ I'll format it correctly as a tool call)\n" + type: reasoning_text + encrypted_content: null + id: rs_9327d10a4a99e129 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "weather tool"}' + call_id: chatcmpl-tool-a8fc33ab402e4ff0 + caller: null + id: fc_857408fee70af6a5 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-a8fc33ab402e4ff0 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: true + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: + - 'event: response.created + + ' + - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"82d95d7ea20b54de","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"The","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" user wants me","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to call the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `get_weather","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` tool exactly","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" once with the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" city","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"Paris\".","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nI have","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" already called","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` as","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" requested in","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the previous turn","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nThe","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" current request explicitly","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tells","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" me to use","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"get_weather`","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" with parameters","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `{\"city","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\":\"","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris\"}`","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" and","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" not to call","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` again.","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nI will","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" construct","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the function","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call for `","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"get_weather`.","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nParameters","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": city =","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"Paris\".","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nFunction","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" name","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": get_weather","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nCall","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool.\n","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":48,"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already called `tool_search` as requested in the previous turn.\nThe current + request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` + and not to call `tool_search` again.\nI will construct the function call for + `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall + the tool.\n","type":"response.reasoning_text.done"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"part":{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already called `tool_search` as requested in the previous turn.\nThe current + request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` + and not to call `tool_search` again.\nI will construct the function call for + `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall + the tool.\n","type":"reasoning_text"},"sequence_number":49,"type":"response.reasoning_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"82d95d7ea20b54de","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already called `tool_search` as requested in the previous turn.\nThe current + request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` + and not to call `tool_search` again.\nI will construct the function call for + `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall + the tool.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":50,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"arguments":"","call_id":"call_bbdce13c58516370","name":"get_weather","type":"function_call","id":"91decd0f7ded9c1c","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":51,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"{\"city\": \"","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":52,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"Paris","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":53,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"\"}","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":54,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"arguments":"{\"city\": \"Paris\"}","item_id":"91decd0f7ded9c1c","name":"get_weather","output_index":1,"sequence_number":55,"type":"response.function_call_arguments.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_bbdce13c58516370","name":"get_weather","type":"function_call","id":"91decd0f7ded9c1c","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":56,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_8249dd1d18d7cab2","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already called `tool_search` as requested in the previous turn.\nThe current + request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` + and not to call `tool_search` again.\nI will construct the function call for + `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall + the tool.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"city\": + \"Paris\"}","call_id":"chatcmpl-tool-8289918f90de051c","name":"get_weather","type":"function_call","id":"fc_bb8169afc12efa5f","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":562,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":131,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":693},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":57,"type":"response.completed"} + + ' + - ' + + ' + status_code: 200 +- filename: t3 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once to find a weather\ + \ tool.\nI should not call `get_weather` yet.\nThe description for `tool_search`\ + \ says: \"Search the client tool catalog. Available catalog entry: get_weather\ + \ — Get the current weather for a city.\"\nI need to provide a query for\ + \ the tool search.\nQuery: \"find a weather tool\"\nI will call `tool_search`\ + \ with this query.\nWait, the prompt says \"First call tool_search exactly\ + \ once to find a weather tool. Do not call get_weather yet.\"\nSo I just\ + \ call `tool_search`.\nLet's do it.\nQuery: \"weather tool\"\nParameters:\ + \ `{\"query\": \"weather tool\"}`\nDone. \nChecking strictness: `allowed_callers`\ + \ is null, `defer_loading` is null. Only `tool_search` is available.\n\ + Proceed. \nOutput matches the format.\nI will generate the tool call.\ + \ \nNote: I should just output the tool call.\n```json\n{\n \"name\"\ + : \"tool_search\",\n \"parameters\": {\n \"query\": \"weather tool\"\ + \n }\n}\n```\nActually, the prompt says \"First call tool_search exactly\ + \ once to find a weather tool.\" I'll just make the call.\nDone. \nWait,\ + \ let's verify the schema: `tool_search` takes `query` (string).\nAll\ + \ good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement\ + \ during thought)\nShould I be more specific in the query? \"find a weather\ + \ tool\" is fine.\nI will generate the call. \nDone. \nProceeding. \n\ + [Output Generation]\ntool_search(query=\"weather tool\")\nDone. \n(Note:\ + \ I'll format it correctly as a tool call)\n" + type: reasoning_text + encrypted_content: null + id: rs_9327d10a4a99e129 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "weather tool"}' + call_id: chatcmpl-tool-a8fc33ab402e4ff0 + caller: null + id: fc_857408fee70af6a5 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-a8fc33ab402e4ff0 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + - content: + - text: 'The user wants me to call the `get_weather` tool exactly once with + the city "Paris". + + I have already called `tool_search` as requested in the previous turn. + + The current request explicitly tells me to use the tool `get_weather` + with parameters `{"city":"Paris"}` and not to call `tool_search` again. + + I will construct the function call for `get_weather`. + + Parameters: city = "Paris". + + Function name: get_weather. + + Call the tool. + + ' + type: reasoning_text + encrypted_content: null + id: rs_8249dd1d18d7cab2 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-8289918f90de051c + caller: null + id: fc_bb8169afc12efa5f + name: get_weather + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-8289918f90de051c + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: true + tool_choice: auto + tools: + - description: 'Search the client tool catalog. Available catalog entry: get_weather + — Get the current weather for a city.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: + - 'event: response.created + + ' + - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"905f67d33894f159","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"The","item_id":"905f67d33894f159","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" user wants me","item_id":"905f67d33894f159","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to use","item_id":"905f67d33894f159","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the function output","item_id":"905f67d33894f159","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" and reply with","item_id":"905f67d33894f159","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exactly \"PAR","item_id":"905f67d33894f159","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"IS_WEATHER","item_id":"905f67d33894f159","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_OK\".\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"No","item_id":"905f67d33894f159","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" more","item_id":"905f67d33894f159","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tools should be","item_id":"905f67d33894f159","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" called.\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"The function output","item_id":"905f67d33894f159","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" for","item_id":"905f67d33894f159","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Paris","item_id":"905f67d33894f159","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is","item_id":"905f67d33894f159","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": {\"city","item_id":"905f67d33894f159","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\":\"Paris\",\"","item_id":"905f67d33894f159","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"condition\":\"clear","item_id":"905f67d33894f159","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\",\"temperature_c","item_id":"905f67d33894f159","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\":21","item_id":"905f67d33894f159","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"}\nI","item_id":"905f67d33894f159","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" need","item_id":"905f67d33894f159","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to output exactly","item_id":"905f67d33894f159","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"PARIS","item_id":"905f67d33894f159","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_WEATHER_OK","item_id":"905f67d33894f159","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\".\nDone","item_id":"905f67d33894f159","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"sequence_number":32,"text":"The + user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo + more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI + need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"response.reasoning_text.done"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"part":{"text":"The + user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo + more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI + need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"},"sequence_number":33,"type":"response.reasoning_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"905f67d33894f159","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo + more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI + need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":34,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"8e30a80425578155","content":[],"role":"assistant","status":"in_progress","type":"message","phase":null},"output_index":1,"sequence_number":35,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"content_index":0,"item_id":"8e30a80425578155","output_index":1,"part":{"annotations":[],"text":"","type":"output_text","logprobs":[]},"sequence_number":36,"type":"response.content_part.added"} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n\nPARIS","item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":37,"type":"response.output_text.delta"} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"content_index":0,"delta":"_WEATHER_OK","item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":38,"type":"response.output_text.delta"} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"content_index":0,"item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":39,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"content_index":0,"item_id":"8e30a80425578155","output_index":1,"part":{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null},"sequence_number":40,"type":"response.content_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"8e30a80425578155","content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null,"summary":[]},"output_index":1,"sequence_number":41,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_985f3beffd5ba701","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo + more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI + need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"id":"msg_9a4dd17402285ad2","content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog. Available catalog entry: get_weather — Get the current + weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":639,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":76,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":715},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":42,"type":"response.completed"} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml new file mode 100644 index 00000000..a323acc0 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml @@ -0,0 +1,391 @@ +turns: +- filename: t1 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + tools: + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: 1787143209 + error: null + id: resp_01a01a09-3976-7c02-8339-d626463f9f50 + incomplete_details: null + instructions: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: "The user wants me to call `tool_search` exactly once with a query\ + \ related to finding a weather tool.\nI should not call `get_weather`\ + \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ + \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ + \ \"Get the current weather\".\nI will call `tool_search` with query \"\ + Get the current weather\".\nNo other steps needed.\nI will generate the\ + \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ + \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ + \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ + weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ + First call tool_search exactly once to find a weather tool.\"\nI will\ + \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ + Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ + \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ + weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ + The instruction says \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ + Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ + Calling tool_search...\n" + type: reasoning_text + encrypted_content: null + id: rs_ad4906c13b6d5210 + status: null + summary: [] + type: reasoning + - arguments: + query: weather + call_id: chatcmpl-tool-814996d1dda1ff69 + execution: client + id: tsc_b68f545efa494bdf + status: completed + type: tool_search_call + previous_response_id: null + status: completed + usage: + input_tokens: 357 + input_tokens_details: + cached_tokens: 0 + output_tokens: 364 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 721 + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once with a query\ + \ related to finding a weather tool.\nI should not call `get_weather`\ + \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ + \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ + \ \"Get the current weather\".\nI will call `tool_search` with query \"\ + Get the current weather\".\nNo other steps needed.\nI will generate the\ + \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ + \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ + \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ + weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ + First call tool_search exactly once to find a weather tool.\"\nI will\ + \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ + Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ + \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ + weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ + The instruction says \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ + Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ + Calling tool_search...\n" + type: reasoning_text + encrypted_content: null + id: rs_ad4906c13b6d5210 + status: null + summary: [] + type: reasoning + - arguments: + query: weather + call_id: chatcmpl-tool-814996d1dda1ff69 + execution: client + id: tsc_b68f545efa494bdf + status: completed + type: tool_search_call + - call_id: chatcmpl-tool-814996d1dda1ff69 + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + 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: 1787143209 + error: null + id: resp_01a01a09-4133-79a2-9d5d-1e3b3de51748 + incomplete_details: null + instructions: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to call the `get_weather` tool with the argument + `{"city":"Paris"}`. + + I must follow the instruction to call it exactly once and not call `tool_search` + again. + + The previous `tool_search` result confirmed the existence of the `get_weather` + tool. + + I will now proceed with calling `get_weather`. + + ' + type: reasoning_text + encrypted_content: null + id: rs_911e09fba32ab331 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-97872ecbe52a4fe5 + id: fc_8a4c66d835ca3882 + name: get_weather + status: completed + type: function_call + previous_response_id: null + status: completed + usage: + input_tokens: 443 + input_tokens_details: + cached_tokens: 0 + output_tokens: 102 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 545 + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + input: + - content: First call tool_search exactly once to find a weather tool. Do not + call get_weather yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once with a query\ + \ related to finding a weather tool.\nI should not call `get_weather`\ + \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ + \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ + \ \"Get the current weather\".\nI will call `tool_search` with query \"\ + Get the current weather\".\nNo other steps needed.\nI will generate the\ + \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ + \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ + \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ + weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ + First call tool_search exactly once to find a weather tool.\"\nI will\ + \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ + Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ + \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ + weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ + The instruction says \"First call tool_search exactly once to find a weather\ + \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ + Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ + Calling tool_search...\n" + type: reasoning_text + encrypted_content: null + id: rs_ad4906c13b6d5210 + status: null + summary: [] + type: reasoning + - arguments: + query: weather + call_id: chatcmpl-tool-814996d1dda1ff69 + execution: client + id: tsc_b68f545efa494bdf + status: completed + type: tool_search_call + - call_id: chatcmpl-tool-814996d1dda1ff69 + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + - content: + - text: 'The user wants me to call the `get_weather` tool with the argument + `{"city":"Paris"}`. + + I must follow the instruction to call it exactly once and not call `tool_search` + again. + + The previous `tool_search` result confirmed the existence of the `get_weather` + tool. + + I will now proceed with calling `get_weather`. + + ' + type: reasoning_text + encrypted_content: null + id: rs_911e09fba32ab331 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-97872ecbe52a4fe5 + id: fc_8a4c66d835ca3882 + name: get_weather + status: completed + type: function_call + - call_id: chatcmpl-tool-97872ecbe52a4fe5 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: auto + 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: 1787143211 + error: null + id: resp_01a01a09-445a-7220-99bc-668e286b2deb + incomplete_details: null + instructions: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: "The user wants me to use the function output and reply with exactly\ + \ \"PARIS_WEATHER_OK\".\nThe previous function call returned: {\"city\"\ + :\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nThe user's instruction\ + \ is clear: \"Use the function output and call no more tools. Reply with\ + \ exactly PARIS_WEATHER_OK.\"\nSo I will just output \"PARIS_WEATHER_OK\"\ + .\nNo additional tools should be called.\nDone. \nWait, let's double check.\ + \ The prompt says \"Use the function output and call no more tools. Reply\ + \ with exactly PARIS_WEATHER_OK.\"\nThis is a simple instruction to just\ + \ output the exact string.\nI will output exactly \"PARIS_WEATHER_OK\"\ + .\nAll steps followed.\nProceeding. \nOutput matches requirement.✅\n" + type: reasoning_text + encrypted_content: null + id: rs_8c483526383fac76 + status: null + summary: [] + type: reasoning + - content: + - annotations: [] + text: ' + + + PARIS_WEATHER_OK' + type: output_text + id: msg_be93fa93eeadeadd + role: assistant + status: completed + type: message + previous_response_id: null + status: completed + usage: + input_tokens: 520 + input_tokens_details: + cached_tokens: 0 + output_tokens: 172 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 692 + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml new file mode 100644 index 00000000..f461915a --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml @@ -0,0 +1,1869 @@ +turns: +- filename: t1 + request: + body: + input: First call tool_search exactly once to find a weather tool. Do not call + get_weather yet. + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: true + stream: true + tool_choice: auto + tools: + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: + - 'event: response.created + + ' + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143213,"frequency_penalty":0.0,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143213,"frequency_penalty":0.0,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"a01a0b847a293fe9","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user wants me","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + to call `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + exactly once to","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + find a weather","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + tool.\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":"They","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + specifically","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" + said \"","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"Do + not call","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":" + get_weather yet","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":".\"\nThe","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" + `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" + function takes a","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" + `query`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" + parameter.\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"I + need to","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + search for a","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" + weather tool.","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":"\n\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"Parameters","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":":","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"\n-","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" + query: \"","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":"weather + tool\"","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" + or \"get","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" + weather\"\n\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":"Let''s + construct","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + the `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + call.\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":"`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"tool_search(query","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":"=\"weather + tool","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":"\")` + or","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + `tool_search","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":"(query=\"get","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" + weather","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":"\")`\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"The + description says","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":":","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":" + \"get","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":"_weather + — Get","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":" + the current weather","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" + for a city","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":".\"\nI","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" + will use `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":"query","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":"=\"weather\"","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":"`.\nExecuting","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":" + tool","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":" + call. \n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":"Wait, + I","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":" + should just use","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":" + `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + with `","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":"query=\"weather","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":"\"`.\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":"Done. + \n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"Output","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":" + matches the","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":68,"output_index":0,"content_index":0,"delta":" + required","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":69,"output_index":0,"content_index":0,"delta":" + format.\n","item_id":"a01a0b847a293fe9"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":70,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nThey + specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function + takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- + query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` + call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe + description says: \"get_weather — Get the current weather for a city.\"\nI will + use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` + with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":71,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","part":{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nThey + specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function + takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- + query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` + call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe + description says: \"get_weather — Get the current weather for a city.\"\nI will + use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` + with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":72,"output_index":0,"item":{"content":[{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nThey + specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function + takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- + query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` + call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe + description says: \"get_weather — Get the current weather for a city.\"\nI will + use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` + with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a01a0b847a293fe9","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":73,"output_index":1,"item":{"arguments":{},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"in_progress","type":"tool_search_call"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":74,"output_index":1,"item":{"arguments":{"query":"weather"},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"completed","type":"tool_search_call"}} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","sequence_number":75,"response":{"conversation_id":null,"created_at":1787143214,"error":null,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call `tool_search` exactly once to find a weather tool.\nThey + specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function + takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- + query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` + call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe + description says: \"get_weather — Get the current weather for a city.\"\nI will + use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` + with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a01a0b847a293fe9","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"weather"},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":190,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":547}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_9cb73148dea9cad4 + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a01a09-501c-7c80-b65a-9e2977a11bd6 + store: true + stream: true + tool_choice: auto + 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: + - 'event: response.created + + ' + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143214,"frequency_penalty":0.0,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143214,"frequency_penalty":0.0,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"9df21f12d14e7185","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user wants me","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + to call the","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + `get_weather","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":"` + tool exactly","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + once with the","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + city","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + \"Paris\".","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":"\nI + have","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" + already found the","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":" + tool using `","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"tool_search`.","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"\nThe","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" + parameters","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" + for `get","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"_weather` + are","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":":\n","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"- + `city","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"`: + \"","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":"Paris\"\n\n","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":"I + will proceed","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + with the function","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" + call.\n","item_id":"9df21f12d14e7185"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":27,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already found the tool using `tool_search`.\nThe parameters for `get_weather` + are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":28,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","part":{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already found the tool using `tool_search`.\nThe parameters for `get_weather` + are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":29,"output_index":0,"item":{"content":[{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already found the tool using `tool_search`.\nThe parameters for `get_weather` + are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9df21f12d14e7185","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":30,"output_index":1,"item":{"arguments":"","call_id":"call_8860d1fc26b80da3","caller":null,"id":"a66196e5caa2e08b","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"}} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":31,"output_index":1,"delta":"{\"city\": + \"","item_id":"a66196e5caa2e08b"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":32,"output_index":1,"delta":"Paris","item_id":"a66196e5caa2e08b"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":33,"output_index":1,"delta":"\"}","item_id":"a66196e5caa2e08b"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"type":"response.function_call_arguments.done","sequence_number":34,"output_index":1,"arguments":"{\"city\": + \"Paris\"}","item_id":"a66196e5caa2e08b","name":"get_weather"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":35,"output_index":1,"item":{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_8860d1fc26b80da3","caller":null,"id":"a66196e5caa2e08b","name":"get_weather","namespace":null,"status":"completed","type":"function_call"}} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","sequence_number":36,"response":{"conversation_id":null,"created_at":1787143214,"error":null,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI + have already found the tool using `tool_search`.\nThe parameters for `get_weather` + are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9df21f12d14e7185","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_8860d1fc26b80da3","id":"a66196e5caa2e08b","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","status":"completed","usage":{"input_tokens":554,"input_tokens_details":{"cached_tokens":0},"output_tokens":88,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":642}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 +- filename: t3 + request: + body: + input: + - call_id: call_8860d1fc26b80da3 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a01a09-54b6-7171-8f20-ac680472ed04 + store: true + stream: true + tool_choice: auto + 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: + - 'event: response.created + + ' + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143215,"frequency_penalty":0.0,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143215,"frequency_penalty":0.0,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"8fda27ec54402330","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user wants me","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + to use","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + the function output","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + and call","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + no more tools","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":".\nThen","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + I need","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + to reply with","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" + exactly \"PAR","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"IS_WEATHER","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_OK\".\n","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"I","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" + have the","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" + weather","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" + for","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" + Paris: temperature","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" + 21","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"°C, + clear","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":".\nI","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" + will just reply","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + with \"PAR","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"IS_WEATHER","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":"_OK\".\n","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"Done","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":". + \n","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":"Checking","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" + constraints:\n","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":"- + Use","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":" + the","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + function output?","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + Yes.","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":"\n- + Call","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" + no more tools","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"? + Yes.","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":"\n- + Reply","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + with exactly PARIS","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":"_WEATHER_OK","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":"? + Yes.","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":"\nProceed","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":". + \nOutput","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":": + PARIS_WE","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":"ATHER_OK\n","item_id":"8fda27ec54402330"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":47,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","text":"The + user wants me to use the function output and call no more tools.\nThen I need + to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature + 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking + constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- + Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":48,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","part":{"text":"The + user wants me to use the function output and call no more tools.\nThen I need + to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature + 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking + constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- + Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":49,"output_index":0,"item":{"content":[{"text":"The + user wants me to use the function output and call no more tools.\nThen I need + to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature + 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking + constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- + Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8fda27ec54402330","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":50,"output_index":1,"item":{"content":[],"id":"ba47ead3ac614277","phase":null,"role":"assistant","status":"in_progress","type":"message"}} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","sequence_number":51,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":52,"output_index":1,"content_index":0,"delta":"\n\nPAR","item_id":"ba47ead3ac614277","logprobs":[]} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":53,"output_index":1,"content_index":0,"delta":"IS_WEATHER","item_id":"ba47ead3ac614277","logprobs":[]} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":54,"output_index":1,"content_index":0,"delta":"_OK","item_id":"ba47ead3ac614277","logprobs":[]} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","sequence_number":55,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","logprobs":[],"text":"\n\nPARIS_WEATHER_OK"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","sequence_number":56,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":57,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"ba47ead3ac614277","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","sequence_number":58,"response":{"conversation_id":null,"created_at":1787143215,"error":null,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to use the function output and call no more tools.\nThen I need + to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature + 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking + constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- + Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8fda27ec54402330","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"ba47ead3ac614277","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","status":"completed","usage":{"input_tokens":631,"input_tokens_details":{"cached_tokens":0},"output_tokens":115,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":746}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml new file mode 100644 index 00000000..300ce809 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml @@ -0,0 +1,1306 @@ +turns: +- filename: t1 + request: + body: + input: First call tool_search exactly once to find a weather tool. Do not call + get_weather yet. + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: true + tool_choice: auto + tools: + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"952e282906b2213b","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"952e282906b2213b","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to call","item_id":"952e282906b2213b","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the","item_id":"952e282906b2213b","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `tool_search","item_id":"952e282906b2213b","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` function exactly","item_id":"952e282906b2213b","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" once.\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The description","item_id":"952e282906b2213b","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" of","item_id":"952e282906b2213b","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the request","item_id":"952e282906b2213b","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool\".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"I need","item_id":"952e282906b2213b","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to construct","item_id":"952e282906b2213b","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a","item_id":"952e282906b2213b","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" query","item_id":"952e282906b2213b","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that matches this","item_id":"952e282906b2213b","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".","item_id":"952e282906b2213b","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nThe available","item_id":"952e282906b2213b","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool is `","item_id":"952e282906b2213b","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_weather`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nI will","item_id":"952e282906b2213b","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_search` with","item_id":"952e282906b2213b","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the query \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" or similar.","item_id":"952e282906b2213b","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user explicitly said","item_id":"952e282906b2213b","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Do not","item_id":"952e282906b2213b","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" yet.\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n\nParameters","item_id":"952e282906b2213b","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_search`:\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query","item_id":"952e282906b2213b","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"\n\nPlan","item_id":"952e282906b2213b","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":":\n1","item_id":"952e282906b2213b","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". Call `","item_id":"952e282906b2213b","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search`","item_id":"952e282906b2213b","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" with the","item_id":"952e282906b2213b","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" query \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\".\n2","item_id":"952e282906b2213b","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". Wait for","item_id":"952e282906b2213b","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the result.","item_id":"952e282906b2213b","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n3.","item_id":"952e282906b2213b","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Do not call","item_id":"952e282906b2213b","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n\nLet''s","item_id":"952e282906b2213b","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" execute the call","item_id":"952e282906b2213b","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"sequence_number":62,"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"reasoning_text"},"sequence_number":63,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to call the `tool_search` + function exactly once.\nThe description of the request is \"find a weather tool\".\nI + need to construct a query that matches this.\nThe available tool is `get_weather`.\nI + will call `tool_search` with the query \"find a weather tool\" or similar.\nThe + user explicitly said \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: + \"find a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find + a weather tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s + execute the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":64,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"arguments":{},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":65,"type":"response.output_item.added"} + + ' + - 'event: response.output_item.done + + data: {"item":{"arguments":{"query":"find a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":66,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787143218,"error":null,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"find + a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":174,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":531}},"sequence_number":67,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"952e282906b2213b","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"952e282906b2213b","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to call","item_id":"952e282906b2213b","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"952e282906b2213b","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `tool_search","item_id":"952e282906b2213b","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` function exactly","item_id":"952e282906b2213b","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" once.\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The description","item_id":"952e282906b2213b","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" of","item_id":"952e282906b2213b","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the request","item_id":"952e282906b2213b","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool\".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"I need","item_id":"952e282906b2213b","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to construct","item_id":"952e282906b2213b","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a","item_id":"952e282906b2213b","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query","item_id":"952e282906b2213b","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that matches this","item_id":"952e282906b2213b","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".","item_id":"952e282906b2213b","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nThe available","item_id":"952e282906b2213b","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool is `","item_id":"952e282906b2213b","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nI will","item_id":"952e282906b2213b","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_search` with","item_id":"952e282906b2213b","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the query \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" or similar.","item_id":"952e282906b2213b","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user explicitly said","item_id":"952e282906b2213b","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Do not","item_id":"952e282906b2213b","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" yet.\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\nParameters","item_id":"952e282906b2213b","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_search`:\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query","item_id":"952e282906b2213b","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"\n\nPlan","item_id":"952e282906b2213b","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":":\n1","item_id":"952e282906b2213b","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". Call `","item_id":"952e282906b2213b","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"952e282906b2213b","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with the","item_id":"952e282906b2213b","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\".\n2","item_id":"952e282906b2213b","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". Wait for","item_id":"952e282906b2213b","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the result.","item_id":"952e282906b2213b","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n3.","item_id":"952e282906b2213b","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Do not call","item_id":"952e282906b2213b","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\nLet''s","item_id":"952e282906b2213b","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" execute the call","item_id":"952e282906b2213b","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"sequence_number":62,"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"reasoning_text"},"sequence_number":63,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to call the `tool_search` function + exactly once.\nThe description of the request is \"find a weather tool\".\nI + need to construct a query that matches this.\nThe available tool is `get_weather`.\nI + will call `tool_search` with the query \"find a weather tool\" or similar.\nThe + user explicitly said \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: + \"find a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find + a weather tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s + execute the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":64,"type":"response.output_item.done"}' + - '{"item":{"arguments":{},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":65,"type":"response.output_item.added"}' + - '{"item":{"arguments":{"query":"find a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":66,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787143218,"error":null,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `tool_search` function exactly once.\nThe description + of the request is \"find a weather tool\".\nI need to construct a query that + matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` + with the query \"find a weather tool\" or similar.\nThe user explicitly said + \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find + a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather + tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute + the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"find + a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":174,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":531}},"sequence_number":67,"type":"response.completed"}' +- filename: t2 + request: + body: + input: + - call_id: call_97fc0ce62a91df3b + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a01a09-61c7-7421-92a8-072919c4fe24 + store: true + tool_choice: auto + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"8ca518951f0614c8","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to call the","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` tool with","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the parameter","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `{\"city","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\": \"Paris","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"}`.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nI have","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" already performed","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search`","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in the previous","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" turn.\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"I must call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` exactly once","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nI","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" must not call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `tool_search","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` again.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Tool","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`\nParameters","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"{\"city\":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Paris\"","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"}`\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"sequence_number":35,"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"},"sequence_number":36,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to call the `get_weather` + tool with the parameter `{\"city\": \"Paris\"}`.\nI have already performed the + `tool_search` in the previous turn.\nI must call `get_weather` exactly once.\nI + must not call `tool_search` again.\n\nTool: `get_weather`\nParameters: `{\"city\": + \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":37,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"arguments":"","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":38,"type":"response.output_item.added"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"{\"city\": \"","item_id":"a96229fa504fb524","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"Paris","item_id":"a96229fa504fb524","output_index":1,"sequence_number":40,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"\"}","item_id":"a96229fa504fb524","output_index":1,"sequence_number":41,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.done + + data: {"arguments":"{\"city\": \"Paris\"}","item_id":"a96229fa504fb524","name":"get_weather","output_index":1,"sequence_number":42,"type":"response.function_call_arguments.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":43,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787143219,"error":null,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_a7b21478da87605a","id":"a96229fa504fb524","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","status":"completed","usage":{"input_tokens":557,"input_tokens_details":{"cached_tokens":0},"output_tokens":108,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":665}},"sequence_number":44,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"8ca518951f0614c8","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to call the","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` tool with","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the parameter","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `{\"city","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\": \"Paris","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"}`.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nI have","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" already performed","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the previous","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" turn.\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"I must call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` exactly once","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nI","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" must not call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `tool_search","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` again.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Tool","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`\nParameters","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"{\"city\":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Paris\"","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"}`\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"sequence_number":35,"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"},"sequence_number":36,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to call the `get_weather` tool + with the parameter `{\"city\": \"Paris\"}`.\nI have already performed the `tool_search` + in the previous turn.\nI must call `get_weather` exactly once.\nI must not call + `tool_search` again.\n\nTool: `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":37,"type":"response.output_item.done"}' + - '{"item":{"arguments":"","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":38,"type":"response.output_item.added"}' + - '{"delta":"{\"city\": \"","item_id":"a96229fa504fb524","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.delta"}' + - '{"delta":"Paris","item_id":"a96229fa504fb524","output_index":1,"sequence_number":40,"type":"response.function_call_arguments.delta"}' + - '{"delta":"\"}","item_id":"a96229fa504fb524","output_index":1,"sequence_number":41,"type":"response.function_call_arguments.delta"}' + - '{"arguments":"{\"city\": \"Paris\"}","item_id":"a96229fa504fb524","name":"get_weather","output_index":1,"sequence_number":42,"type":"response.function_call_arguments.done"}' + - '{"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":43,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787143219,"error":null,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_weather` tool with the parameter `{\"city\": + \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI + must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: + `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_a7b21478da87605a","id":"a96229fa504fb524","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","status":"completed","usage":{"input_tokens":557,"input_tokens_details":{"cached_tokens":0},"output_tokens":108,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":665}},"sequence_number":44,"type":"response.completed"}' +- filename: t3 + request: + body: + input: + - call_id: call_a7b21478da87605a + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a01a09-660f-7b42-97dc-8390896b01d7 + store: true + tool_choice: auto + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"a82a3a2c33325074","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to use","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the function output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" from","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the previous step","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" (","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"which provided","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" data","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for Paris)","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nI","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" need to reply","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" with exactly \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ATHER_OK\".","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n\nPrevious","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": {\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city\":\"Paris","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\",\"condition\":\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"clear\",\"temperature","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_c\":2","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"1}\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Instruction","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Use the function","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" output and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". Reply with","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" exactly PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ATHER_OK.\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n\nI will","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" just","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" output the required","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" string.\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"sequence_number":42,"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"},"sequence_number":43,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to use the function output + from the previous step (which provided weather data for Paris) and call no more + tools.\nI need to reply with exactly \"PARIS_WEATHER_OK\".\n\nPrevious output: + {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":44,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":[],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":45,"type":"response.output_item.added"} + + ' + - 'event: response.content_part.added + + data: {"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":46,"type":"response.content_part.added"} + + ' + - 'event: response.output_text.delta + + data: {"content_index":0,"delta":"\n\nPAR","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":47,"type":"response.output_text.delta"} + + ' + - 'event: response.output_text.delta + + data: {"content_index":0,"delta":"IS_WEATHER","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":48,"type":"response.output_text.delta"} + + ' + - 'event: response.output_text.delta + + data: {"content_index":0,"delta":"_OK","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":49,"type":"response.output_text.delta"} + + ' + - 'event: response.output_text.done + + data: {"content_index":0,"item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":50,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"} + + ' + - 'event: response.content_part.done + + data: {"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"},"sequence_number":51,"type":"response.content_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":52,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787143220,"error":null,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","status":"completed","usage":{"input_tokens":634,"input_tokens_details":{"cached_tokens":0},"output_tokens":100,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":734}},"sequence_number":53,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"a82a3a2c33325074","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to use","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the function output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" from","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the previous step","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" (","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"which provided","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" data","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for Paris)","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nI","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" need to reply","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with exactly \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ATHER_OK\".","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\nPrevious","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": {\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city\":\"Paris","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\",\"condition\":\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"clear\",\"temperature","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_c\":2","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"1}\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Instruction","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Use the function","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" output and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". Reply with","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" exactly PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ATHER_OK.\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\nI will","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" just","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" output the required","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" string.\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"sequence_number":42,"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"},"sequence_number":43,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to use the function output from + the previous step (which provided weather data for Paris) and call no more tools.\nI + need to reply with exactly \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":44,"type":"response.output_item.done"}' + - '{"item":{"content":[],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":45,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":46,"type":"response.content_part.added"}' + - '{"content_index":0,"delta":"\n\nPAR","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":47,"type":"response.output_text.delta"}' + - '{"content_index":0,"delta":"IS_WEATHER","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":48,"type":"response.output_text.delta"}' + - '{"content_index":0,"delta":"_OK","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":49,"type":"response.output_text.delta"}' + - '{"content_index":0,"item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":50,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"}' + - '{"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"},"sequence_number":51,"type":"response.content_part.done"}' + - '{"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":52,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787143220,"error":null,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to use the function output from the previous step (which provided + weather data for Paris) and call no more tools.\nI need to reply with exactly + \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: + \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI + will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","status":"completed","usage":{"input_tokens":634,"input_tokens_details":{"cached_tokens":0},"output_tokens":100,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":734}},"sequence_number":53,"type":"response.completed"}' diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml new file mode 100644 index 00000000..e2a4da4d --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml @@ -0,0 +1,398 @@ +turns: +- filename: t1 + request: + body: + input: First call tool_search exactly once to find a weather tool. Do not call + get_weather yet. + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + store: true + stream: false + tool_choice: auto + tools: + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: 1787143193 + created_at: 1787143192 + error: null + frequency_penalty: 0.0 + id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + incomplete_details: null + instructions: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - arguments: + query: Find a weather tool that can retrieve current weather or forecasts + for a specified location. + call_id: call_91mV1KhBtJ05ArOW1Y9Tqy9p + execution: client + id: tsc_0380aab4c0242690006a85a418cf1c8198b139d12cac6deecb + status: completed + type: tool_search_call + parallel_tool_calls: false + 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: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 158 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 35 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 193 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_91mV1KhBtJ05ArOW1Y9Tqy9p + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + store: true + stream: false + tool_choice: auto + 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: 1787143196 + created_at: 1787143193 + error: null + frequency_penalty: 0.0 + id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + incomplete_details: null + instructions: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - arguments: '{"city":"Paris"}' + call_id: call_QPFOdlc7uFi7ErezgobLOClb + id: fc_0380aab4c0242690006a85a41b8dc081989bfd45b69d354b77 + name: get_weather + status: completed + type: function_call + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + 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: Get the current weather for a city. + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 228 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 18 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 246 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + input: + - call_id: call_QPFOdlc7uFi7ErezgobLOClb + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + store: true + stream: false + tool_choice: auto + 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: 1787143198 + created_at: 1787143197 + error: null + frequency_penalty: 0.0 + id: resp_0380aab4c0242690006a85a41cdc948198b7bbbdc8ee5b8dbc + incomplete_details: null + instructions: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: PARIS_WEATHER_OK + type: output_text + id: msg_0380aab4c0242690006a85a41dbdf08198acde9c13de42635a + phase: final_answer + role: assistant + status: completed + type: message + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + 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: Get the current weather for a city. + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 294 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 9 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 303 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml new file mode 100644 index 00000000..566e6160 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml @@ -0,0 +1,409 @@ +turns: +- filename: t1 + request: + body: + input: First call tool_search exactly once to find a weather tool. Do not call + get_weather yet. + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + store: true + stream: true + tool_choice: auto + tools: + - description: Search the client tool catalog for a tool that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capability. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"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_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"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":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"in_progress","arguments":{},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"completed","arguments":{"query":"Find + a tool that can retrieve current weather conditions or forecasts for a specified + location."},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"},"output_index":0,"sequence_number":3} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"completed","background":false,"completed_at":1787143201,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"completed","arguments":{"query":"Find + a tool that can retrieve current weather conditions or forecasts for a specified + location."},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"}],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":158,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":35,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":193},"user":null,"metadata":{}},"sequence_number":4} + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_XZZLgyiW1cF7bDRZYpklBQQD + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city. + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + tool_search again. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba + store: true + stream: true + tool_choice: auto + 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_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"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_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"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":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"in_progress","arguments":"","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"M3YKbiOKkGB6Um","output_index":0,"sequence_number":3} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"fMK8xwuVIvSU","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"2bAEWyfSN5mEQ","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"K4eksVqXSfe","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"HTl4OYLLiYMhBd","output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"},"output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"completed","background":false,"completed_at":1787143203,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":228,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":246},"user":null,"metadata":{}},"sequence_number":10} + + ' + - ' + + ' + status_code: 200 +- filename: t3 + request: + body: + input: + - call_id: call_nWSCHnCJpvRHoQGjLw2qbhQ9 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Use the function output and call no more tools. Reply with exactly + PARIS_WEATHER_OK. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b + store: true + stream: true + tool_choice: auto + 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_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"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_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"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_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","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_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","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":"PAR","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"mQlU8zt6x2Z9O","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"IS","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"vfPELZDwKNwEGv","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_WE","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"D9r0s0ic0u1M4","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"ATHER","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"WLtB6sdOFlz","output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"THEHz2fKYtwJM","output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"output_index":0,"sequence_number":9,"text":"PARIS_WEATHER_OK"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"},"sequence_number":10} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":11} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"completed","background":false,"completed_at":1787143204,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get + the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":294,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":9,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":303},"user":null,"metadata":{}},"sequence_number":12} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json new file mode 100644 index 00000000..6de33939 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json @@ -0,0 +1,19 @@ +[ + { + "type": "function", + "name": "tool_search", + "description": "Search the client tool catalog. Available catalog entry: get_weather — Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "A concise description of the needed capability." + } + }, + "required": ["query"], + "additionalProperties": false + }, + "strict": true + } +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json new file mode 100644 index 00000000..15c7a3b0 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json @@ -0,0 +1,35 @@ +[ + { + "type": "function", + "name": "tool_search", + "description": "Search the client tool catalog. Available catalog entry: get_weather — Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "A concise description of the needed capability." + } + }, + "required": ["query"], + "additionalProperties": false + }, + "strict": true + }, + { + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true + } +] diff --git a/crates/agentic-server-core/tests/compaction_cassette_test.rs b/crates/agentic-server-core/tests/compaction_cassette_test.rs index 774eec75..1642f11c 100644 --- a/crates/agentic-server-core/tests/compaction_cassette_test.rs +++ b/crates/agentic-server-core/tests/compaction_cassette_test.rs @@ -2,10 +2,13 @@ mod support; use std::sync::Arc; -use agentic_core::executor::{compact_response, execute}; +use agentic_core::executor::{compact_response, create_conversation, execute}; use agentic_core::{CompactRequest, InputItem, RequestPayload}; use serde_json::{Value, json}; -use support::{MockResponse, TestFixture, load_cassette, output_text, unwrap_blocking}; +use support::{ + MockResponse, TestFixture, function_call_response, load_cassette, output_text, text_response, tool_search_request, + unwrap_blocking, +}; const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/compaction"); const COMPACTION_PROMPT: &str = "You are performing a CONTEXT CHECKPOINT COMPACTION. Create a concise handoff summary that preserves current progress, decisions, constraints, unresolved work, and critical references for the next model. Return only the summary."; @@ -89,6 +92,58 @@ fn response_request(input: &Value, context_management: Option<&Value>) -> Reques .expect("valid Responses request") } +fn tool_search_declarations() -> Value { + json!([ + { + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }, + { + "type": "function", + "name": "get_time", + "description": "Get time", + "parameters": {"type": "object"}, + "defer_loading": true + } + ]) +} + +fn completed_tool_search_history() -> Value { + json!([ + { + "type": "message", + "role": "user", + "content": "Find the weather tool" + }, + { + "type": "tool_search_call", + "id": "tsc_search", + "call_id": "call_search", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }] + } + ]) +} + fn cassette(name: &str) -> support::Cassette { load_cassette(&format!("{DIR}/{name}")) } @@ -245,3 +300,157 @@ async fn automatic_compaction_replays_both_rounds_and_accumulates_usage() { .all(|request| request.get("context_management").is_none()) ); } + +#[tokio::test] +async fn tool_search_standalone_compaction_lowers_summary_history_and_preserves_loaded_state() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), + text_response("tool loaded"), + text_response("durable tool summary"), + function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), + ]) + .await; + + let first = unwrap_blocking( + execute( + tool_search_request("find weather", Some(tool_search_declarations()), true, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("stored search call"), + ); + let second = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "tool_search_output", + "call_id": "call_search", + "tools": [tool_search_declarations()[1].clone()] + }]), + None, + true, + Some(first.id), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("persist loaded state"), + ); + let compact_request: CompactRequest = serde_json::from_value(json!({ + "model": "test-model", + "previous_response_id": second.id + })) + .expect("valid stored compaction request"); + let compacted = compact_response(compact_request, &fixture.exec_ctx, None) + .await + .expect("standalone tool-search compaction"); + + assert!( + compacted + .output + .iter() + .all(|item| !matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))) + ); + let followup = unwrap_blocking( + execute( + tool_search_request("use the loaded weather tool", None, false, Some(compacted.id), None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("compacted continuation"), + ); + assert_eq!( + serde_json::to_value(&followup.output[0]).unwrap()["name"], + "get_weather" + ); + + let requests = fixture.request_bodies().await; + assert_eq!(requests.len(), 4); + let summary_request = &requests[2]; + assert!( + summary_request["input"] + .as_array() + .unwrap() + .iter() + .any(|item| { item["type"] == "function_call" && item["name"] == "tool_search" }) + ); + assert!(!summary_request.to_string().contains("tool_search_call")); + assert!(!summary_request.to_string().contains("tool_search_output")); + let followup_tools = requests[3]["tools"].as_array().expect("compacted effective tools"); + assert!(followup_tools.iter().any(|tool| tool["name"] == "get_weather")); + assert!(!followup_tools.iter().any(|tool| tool["name"] == "get_time")); +} + +#[tokio::test] +async fn tool_search_automatic_compaction_restores_only_loaded_definition_on_continuation() { + let fixture = TestFixture::new_with_responses(vec![ + text_response("durable automatic tool summary"), + function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), + text_response("automatic compaction complete"), + ]) + .await; + let conversation_id = create_conversation(&fixture.exec_ctx) + .await + .expect("create compacted conversation") + .conversation_id; + let mut request = tool_search_request( + completed_tool_search_history(), + Some(tool_search_declarations()), + true, + None, + Some(conversation_id.clone()), + ); + request.context_management = serde_json::from_value(json!([{ + "type": "compaction", + "compact_threshold": 1 + }])) + .expect("valid automatic compaction policy"); + + let first = unwrap_blocking( + execute(request, Arc::clone(&fixture.exec_ctx)) + .await + .expect("automatic tool-search compaction"), + ); + assert_eq!(serde_json::to_value(&first.output[0]).unwrap()["name"], "get_weather"); + let final_response = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }]), + None, + true, + None, + Some(conversation_id), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("continue automatically compacted state"), + ); + assert_eq!(output_text(&final_response), "automatic compaction complete"); + + let requests = fixture.request_bodies().await; + assert_eq!(requests.len(), 3); + assert!(!requests[0].to_string().contains("tool_search_call")); + assert!(!requests[0].to_string().contains("tool_search_output")); + assert!( + requests[0]["input"] + .as_array() + .unwrap() + .iter() + .any(|item| { item["type"] == "function_call" && item["name"] == "tool_search" }) + ); + assert!(requests[1]["input"].as_array().unwrap().iter().all(|item| { + !(item["type"] == "function_call" && item["name"] == "tool_search" + || item["type"] == "function_call_output" && item["call_id"] == "call_search") + })); + for request in &requests[1..] { + let tools = request["tools"].as_array().expect("effective private tools"); + assert!(tools.iter().any(|tool| tool["name"] == "get_weather")); + assert!(!tools.iter().any(|tool| tool["name"] == "get_time")); + } +} diff --git a/crates/agentic-server-core/tests/stateful_conversation_integration.rs b/crates/agentic-server-core/tests/stateful_conversation_integration.rs index a6174eb5..90090eab 100644 --- a/crates/agentic-server-core/tests/stateful_conversation_integration.rs +++ b/crates/agentic-server-core/tests/stateful_conversation_integration.rs @@ -7,10 +7,12 @@ mod support; use agentic_core::executor::{create_conversation, execute}; +use serde_json::json; use std::sync::Arc; use support::{ - TestFixture, collect_stream, expected_text, load_cassette, make_request, output_text, request_input_texts, - responses_turns, unwrap_blocking, + TestFixture, collect_stream, expected_text, function_call_response, load_cassette, make_request, output_text, + request_input_texts, responses_turns, text_response, tool_search_function_declarations, tool_search_output, + tool_search_request, unwrap_blocking, }; const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/text_only/conversation"); @@ -56,6 +58,204 @@ async fn test_two_turn_nonstreaming_conversation() { assert_eq!(output_text(&p2), expected_text(t2)); } +#[tokio::test] +async fn tool_search_conversation_continuation_recovers_effective_tools_from_latest_metadata() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), + function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), + text_response("conversation weather complete"), + ]) + .await; + let conversation_id = create_conversation(&fixture.exec_ctx) + .await + .expect("create conversation") + .conversation_id; + let declarations = tool_search_function_declarations("get_weather", "Get weather"); + + let first = unwrap_blocking( + execute( + tool_search_request( + "find weather", + Some(declarations), + true, + None, + Some(conversation_id.clone()), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation search turn"), + ); + assert_eq!( + serde_json::to_value(&first.output[0]).unwrap()["type"], + "tool_search_call" + ); + + let second = unwrap_blocking( + execute( + tool_search_request( + json!([tool_search_output("call_search", "get_weather", "Get weather")]), + None, + true, + None, + Some(conversation_id.clone()), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation search output"), + ); + assert_eq!(serde_json::to_value(&second.output[0]).unwrap()["name"], "get_weather"); + + let third = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }]), + None, + true, + None, + Some(conversation_id), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation loaded-function output"), + ); + assert_eq!(output_text(&third), "conversation weather complete"); + + let requests = fixture.request_bodies().await; + assert_eq!(requests.len(), 3); + for request in &requests[1..] { + assert!( + request["tools"] + .as_array() + .expect("inherited tools") + .iter() + .any(|tool| { tool["name"] == "get_weather" && tool.get("defer_loading").is_none() }) + ); + assert!( + request["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "tool_search") + ); + assert!(!request.to_string().contains("tool_search_call")); + assert!(!request.to_string().contains("tool_search_output")); + } +} + +#[tokio::test] +async fn tool_search_previous_response_branch_cannot_replace_conversation_metadata() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_winner", "call_winner", "tool_search", r#"{"query":"winner"}"#), + text_response("winner loaded"), + text_response("winner checkpoint"), + function_call_response("fc_branch", "call_branch", "tool_search", r#"{"query":"branch"}"#), + text_response("branch loaded"), + text_response("conversation resumed"), + ]) + .await; + let conversation_id = create_conversation(&fixture.exec_ctx) + .await + .expect("create conversation") + .conversation_id; + + let winner_search = unwrap_blocking( + execute( + tool_search_request( + "find winner", + Some(tool_search_function_declarations("winner_tool", "Winner tool")), + true, + None, + Some(conversation_id.clone()), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation search call"), + ); + assert_eq!( + serde_json::to_value(&winner_search.output[0]).unwrap()["type"], + "tool_search_call" + ); + let winner = unwrap_blocking( + execute( + tool_search_request( + json!([tool_search_output("call_winner", "winner_tool", "Winner tool")]), + None, + true, + None, + Some(conversation_id.clone()), + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation loaded state"), + ); + assert_eq!(output_text(&winner), "winner loaded"); + + let checkpoint = unwrap_blocking( + execute( + tool_search_request("checkpoint winner", None, true, None, Some(conversation_id.clone())), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation checkpoint"), + ); + assert_eq!(output_text(&checkpoint), "winner checkpoint"); + + let branch_search = unwrap_blocking( + execute( + tool_search_request( + "find branch", + Some(tool_search_function_declarations("branch_tool", "Branch tool")), + true, + Some(checkpoint.id), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("response branch search call"), + ); + let branch = unwrap_blocking( + execute( + tool_search_request( + json!([tool_search_output("call_branch", "branch_tool", "Branch tool")]), + None, + true, + Some(branch_search.id), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("response branch loaded state"), + ); + assert_eq!(output_text(&branch), "branch loaded"); + + let resumed = unwrap_blocking( + execute( + tool_search_request("resume conversation", None, true, None, Some(conversation_id)), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("conversation continuation"), + ); + assert_eq!(output_text(&resumed), "conversation resumed"); + + let requests = fixture.request_bodies().await; + let resumed_tools = requests[5]["tools"].as_array().expect("conversation tools"); + assert!(resumed_tools.iter().any(|tool| tool["name"] == "winner_tool")); + assert!(!resumed_tools.iter().any(|tool| tool["name"] == "branch_tool")); + assert!(!requests[5].to_string().contains("branch loaded")); +} + /// Case 7 — two turns, streaming, via `conversation_id`. #[tokio::test] async fn test_two_turn_streaming_conversation() { diff --git a/crates/agentic-server-core/tests/stateful_responses_integration.rs b/crates/agentic-server-core/tests/stateful_responses_integration.rs index 3d36cce5..617cefea 100644 --- a/crates/agentic-server-core/tests/stateful_responses_integration.rs +++ b/crates/agentic-server-core/tests/stateful_responses_integration.rs @@ -12,11 +12,12 @@ use agentic_core::types::tools::{FunctionToolParam, NonEmptyToolName}; use agentic_core::{FunctionToolResultMessage, InputItem, ResponsesInput, ResponsesTool, ToolChoice}; use either::Either; use futures::StreamExt; -use serde_json::Value; +use serde_json::{Value, json}; use std::sync::Arc; use support::{ - MockResponse, TestFixture, collect_stream, expected_text, load_cassette, make_request, output_text, - request_input_texts, text_response, unwrap_blocking, + MockResponse, TestFixture, collect_stream, expected_text, function_call_response, load_cassette, make_request, + output_text, request_input_texts, text_response, tool_search_function_declarations, tool_search_output, + tool_search_request, unwrap_blocking, }; const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/text_only/responses"); @@ -510,7 +511,7 @@ async fn test_mcp_namespace_showcase_round_trip_rehydrates_calls_tools_and_outpu assert_flat_mcp_showcase_tools(&requests[1]["tools"]); let input = requests[1]["input"].as_array().expect("rehydrated input array"); - assert_namespaced_calls( + assert_flat_namespaced_calls( input, &["echo_text", "add_numbers", "make_slug", "repo_file_head", "search_repo"], ); @@ -562,6 +563,276 @@ async fn test_store_false_with_previous_response_id_hydrates_but_does_not_persis assert!(result.is_err(), "store=false response should not be persisted"); } +#[tokio::test] +async fn tool_search_previous_response_continuation_omits_tools_after_first_turn() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), + function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), + text_response("weather complete"), + ]) + .await; + let declarations = tool_search_function_declarations("get_weather", "Get weather"); + + let first = unwrap_blocking( + execute( + tool_search_request("find weather", Some(declarations), true, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("stored search turn"), + ); + let search_call = serde_json::to_value(&first.output[0]).expect("public search call"); + assert_eq!(search_call["type"], "tool_search_call"); + + let second = unwrap_blocking( + execute( + tool_search_request( + json!([tool_search_output("call_search", "get_weather", "Get weather")]), + None, + true, + Some(first.id.clone()), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("search-output continuation"), + ); + let weather_call = serde_json::to_value(&second.output[0]).expect("public weather call"); + assert_eq!(weather_call["name"], "get_weather"); + + let third = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }]), + None, + true, + Some(second.id.clone()), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("loaded-function continuation"), + ); + assert_eq!(output_text(&third), "weather complete"); + + let requests = fixture.request_bodies().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[1]["input"][1]["type"], "function_call"); + assert_eq!(requests[1]["input"][1]["name"], "tool_search"); + assert!( + requests[1]["tools"] + .as_array() + .expect("inherited tools") + .iter() + .any(|tool| { tool["name"] == "get_weather" && tool.get("defer_loading").is_none() }) + ); + assert!( + requests[1]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "tool_search") + ); + assert!( + requests[2]["tools"] + .as_array() + .expect("replayed tools") + .iter() + .any(|tool| { tool["name"] == "get_weather" && tool.get("defer_loading").is_none() }) + ); + assert!( + !requests + .iter() + .any(|body| body.to_string().contains("tool_search_call")) + ); + assert!( + !requests + .iter() + .any(|body| body.to_string().contains("tool_search_output")) + ); +} + +#[tokio::test] +async fn tool_search_branch_from_earlier_response_does_not_inherit_later_loaded_definition() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), + text_response("loaded branch"), + text_response("empty branch"), + ]) + .await; + let declarations = json!([ + { + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + } + ]); + let first = unwrap_blocking( + execute( + tool_search_request("find weather", Some(declarations), true, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("search turn"), + ); + + let loaded_branch = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "tool_search_output", + "call_id": "call_search", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }]), + None, + true, + Some(first.id.clone()), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("loaded branch"), + ); + assert_eq!(output_text(&loaded_branch), "loaded branch"); + + let empty_branch = unwrap_blocking( + execute( + tool_search_request( + json!([{ + "type": "tool_search_output", + "call_id": "call_search", + "tools": [] + }]), + None, + true, + Some(first.id), + None, + ), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("independent empty branch"), + ); + assert_eq!(output_text(&empty_branch), "empty branch"); + + let requests = fixture.request_bodies().await; + let loaded_tools = requests[1]["tools"].as_array().expect("loaded branch tools"); + let empty_tools = requests[2]["tools"].as_array().expect("empty branch tools"); + assert!(loaded_tools.iter().any(|tool| tool["name"] == "get_weather")); + assert!(!empty_tools.iter().any(|tool| tool["name"] == "get_weather")); +} + +#[tokio::test] +async fn tool_search_store_false_manual_replay_completes_without_reusable_response() { + let fixture = TestFixture::new_with_responses(vec![ + function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), + function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), + text_response("manual replay complete"), + ]) + .await; + let declarations = json!([ + { + "type": "tool_search", + "execution": "client", + "description": "Find a tool", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + } + ]); + let user = json!({"type": "message", "role": "user", "content": "find weather"}); + let first = unwrap_blocking( + execute( + tool_search_request(json!([user.clone()]), Some(declarations), false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("stateless search call"), + ); + let search_call = serde_json::to_value(&first.output[0]).unwrap(); + let search_output = json!({ + "type": "tool_search_output", + "call_id": "call_search", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }); + let second_input = json!([user.clone(), search_call, search_output]); + let second = unwrap_blocking( + execute( + tool_search_request(second_input.clone(), None, false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("stateless loaded call"), + ); + let mut final_input = second_input.as_array().unwrap().clone(); + final_input.push(serde_json::to_value(&second.output[0]).unwrap()); + final_input.push(json!({ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + })); + let final_response = unwrap_blocking( + execute( + tool_search_request(Value::Array(final_input), None, false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("stateless final response"), + ); + assert_eq!(output_text(&final_response), "manual replay complete"); + + let lookup_ctx = RequestContext { + original_request: make_request("lookup", true, false, Some(final_response.id.clone()), None), + enriched_request: make_request("lookup", true, false, Some(final_response.id), None), + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, + new_input_items: Vec::new(), + response_id: "resp_lookup".to_owned(), + conversation_id: None, + conversation_version: None, + }; + let error = fixture + .exec_ctx + .resp_handler + .get(&lookup_ctx) + .await + .expect_err("store:false response ID must not be reusable"); + assert!(matches!(error, agentic_core::executor::ExecutorError::Storage(source) if source.is_not_found())); +} + #[tokio::test] async fn test_previous_response_id_persists_inherited_tools_and_choice() { let fixture = @@ -612,6 +883,9 @@ async fn test_previous_response_id_persists_inherited_tools_and_choice() { previous_response_id: Some(p2.id.clone()), ..make_request("lookup", true, false, None, None) }, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_lookup".into(), conversation_id: None, @@ -795,6 +1069,20 @@ fn assert_namespaced_calls(items: &[Value], expected_names: &[&str]) { } } +fn assert_flat_namespaced_calls(items: &[Value], expected_names: &[&str]) { + for expected_name in expected_names { + let flat_name = format!("agentic_ns__mcp__agentic_fixture__{expected_name}"); + assert!( + items.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call") + && item.get("name").and_then(Value::as_str) == Some(&flat_name) + && item.get("namespace").is_none() + }), + "missing private flat function call {flat_name}" + ); + } +} + fn assert_tool_outputs(items: &[Value], expected_call_ids: &[&str]) { for expected_call_id in expected_call_ids { assert!( diff --git a/crates/agentic-server-core/tests/storage_integration.rs b/crates/agentic-server-core/tests/storage_integration.rs index 62ee2388..cd359803 100644 --- a/crates/agentic-server-core/tests/storage_integration.rs +++ b/crates/agentic-server-core/tests/storage_integration.rs @@ -866,6 +866,223 @@ async fn test_response_store_get_after_persist() { assert_eq!(retrieved.history_item_ids.len(), 2); } +#[tokio::test] +async fn test_tool_search_conversation_snapshot_includes_latest_effective_metadata() { + let pool = setup_pool().await; + let store = ConversationStore::new(Arc::clone(&pool)); + let conversation = store.create().await.expect("create conversation"); + let initial = ResponseMetadata { + model: "initial-model".to_owned(), + ..ResponseMetadata::default() + }; + store + .persist( + &conversation.conversation_id, + "resp_tool_search_initial", + None, + vec![create_input_item("initial")], + &initial, + ) + .await + .expect("persist initial turn"); + + let loaded_tool: agentic_core::types::tools::ResponsesTool = serde_json::from_value(serde_json::json!({ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + })) + .expect("valid loaded function"); + let latest = ResponseMetadata { + model: "latest-model".to_owned(), + effective_tools: Some(vec![loaded_tool.clone()]), + tool_search_loaded_tools: Some(vec![loaded_tool]), + ..ResponseMetadata::default() + }; + store + .persist( + &conversation.conversation_id, + "resp_tool_search_latest", + None, + vec![create_input_item("latest")], + &latest, + ) + .await + .expect("persist latest turn"); + + // Simulate replicas whose clocks and process-local UUID order disagree + // with the committed conversation-item sequence. + sqlx::query("UPDATE responses SET created_at = $1 WHERE id = $2") + .bind(9_999_i64) + .bind("resp_tool_search_initial") + .execute(pool.as_ref()) + .await + .expect("make older turn appear newer by wall clock"); + sqlx::query("UPDATE responses SET created_at = $1 WHERE id = $2") + .bind(1_i64) + .bind("resp_tool_search_latest") + .execute(pool.as_ref()) + .await + .expect("make latest turn appear older by wall clock"); + + let latest_item_id: String = + sqlx::query_scalar("SELECT id FROM items WHERE conversation_id = $1 ORDER BY seq DESC LIMIT 1") + .bind(&conversation.conversation_id) + .fetch_one(pool.as_ref()) + .await + .expect("latest conversation item"); + let branch = ResponseMetadata { + model: "branch-model".to_owned(), + ..ResponseMetadata::default() + }; + sqlx::query( + "INSERT INTO responses \ + (id, conversation_id, previous_response_id, history_item_ids, metadata, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind("resp_tool_search_branch") + .bind(&conversation.conversation_id) + .bind("resp_tool_search_latest") + .bind(serde_json::to_string(&vec![latest_item_id]).expect("branch history JSON")) + .bind(String::try_from(&branch).expect("branch metadata JSON")) + .bind(20_000_i64) + .execute(pool.as_ref()) + .await + .expect("seed conversation-tagged response branch"); + + let snapshot = store + .rehydrate_snapshot(&conversation.conversation_id) + .await + .expect("rehydrate typed snapshot"); + let metadata = snapshot + .latest_response_metadata + .expect("latest response metadata accompanies conversation items"); + assert_eq!(metadata.model, "latest-model"); + assert_eq!(metadata.tool_search_loaded_tools.as_deref().map(<[_]>::len), Some(1)); +} + +#[tokio::test] +async fn test_tool_search_conversation_conflict_does_not_persist_stale_loaded_state() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + let conversation = store.create().await.expect("create conversation"); + let snapshot = store + .rehydrate_snapshot(&conversation.conversation_id) + .await + .expect("capture empty version"); + let winning_tool: agentic_core::types::tools::ResponsesTool = serde_json::from_value(serde_json::json!({ + "type": "function", + "name": "winning_tool", + "parameters": {"type": "object"}, + "defer_loading": true + })) + .expect("winning tool"); + let stale_tool: agentic_core::types::tools::ResponsesTool = serde_json::from_value(serde_json::json!({ + "type": "function", + "name": "stale_tool", + "parameters": {"type": "object"}, + "defer_loading": true + })) + .expect("stale tool"); + let winning = ResponseMetadata { + model: "winner".to_owned(), + effective_tools: Some(vec![winning_tool.clone()]), + tool_search_loaded_tools: Some(vec![winning_tool]), + ..ResponseMetadata::default() + }; + let stale = ResponseMetadata { + model: "stale".to_owned(), + effective_tools: Some(vec![stale_tool.clone()]), + tool_search_loaded_tools: Some(vec![stale_tool]), + ..ResponseMetadata::default() + }; + + store + .persist_if_version( + &conversation.conversation_id, + snapshot.version, + "resp_tool_search_winner", + None, + vec![create_input_item("winner")], + &winning, + ) + .await + .expect("winning turn persists"); + let error = store + .persist_if_version( + &conversation.conversation_id, + snapshot.version, + "resp_tool_search_stale", + None, + vec![create_input_item("stale")], + &stale, + ) + .await + .expect_err("stale turn conflicts"); + assert!(matches!(error, StorageError::ConversationConflict { .. })); + + let latest = store + .rehydrate_snapshot(&conversation.conversation_id) + .await + .expect("rehydrate winning state") + .latest_response_metadata + .expect("winning metadata"); + assert_eq!(latest.model, "winner"); + let serialized = serde_json::to_value(latest.tool_search_loaded_tools).unwrap(); + assert_eq!(serialized[0]["name"], "winning_tool"); + assert!(!serialized.to_string().contains("stale_tool")); +} + +#[tokio::test] +async fn test_tool_search_output_storage_round_trip_redacts_mcp_credentials() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + let output: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_private", + "tools": [{ + "type": "mcp", + "server_label": "private_server", + "server_description": "Private server", + "server_url": "https://mcp.example.test/mcp", + "headers": {"X-API-Key": "header-secret"}, + "authorization": "authorization-secret", + "defer_loading": true + }] + })) + .expect("valid public search output"); + store + .persist( + "resp_tool_search_private", + None, + vec![InOutItem::Input(output)], + &ResponseMetadata::default(), + ) + .await + .expect("persist public search output"); + + let history = store + .rehydrate("resp_tool_search_private") + .await + .expect("rehydrate public search output"); + let InOutItem::Input(InputItem::ToolSearchOutput(output)) = &history[0] else { + panic!("public search output type must survive storage") + }; + let agentic_core::types::tools::ResponsesTool::Mcp(mcp) = &output.tools[0] else { + panic!("MCP declaration must survive storage") + }; + assert_eq!(mcp.server_label, "private_server"); + assert!(mcp.headers.is_none()); + assert!(mcp.authorization.is_none()); + assert!( + serde_json::to_value(mcp) + .expect("stored MCP serializes") + .get("_agentic_discovered_tools") + .is_none() + ); +} + #[tokio::test] async fn test_conversation_get_or_create_same_id() { let pool = setup_pool().await; diff --git a/crates/agentic-server-core/tests/tool_normalization_test.rs b/crates/agentic-server-core/tests/tool_normalization_test.rs index 5cf035c9..399a648b 100644 --- a/crates/agentic-server-core/tests/tool_normalization_test.rs +++ b/crates/agentic-server-core/tests/tool_normalization_test.rs @@ -99,6 +99,9 @@ fn upstream_request_value(payload: RequestPayload, stream: bool) -> Value { let ctx = RequestContext { original_request: payload.clone(), enriched_request: payload, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: "resp_test".to_string(), conversation_id: None, diff --git a/crates/agentic-server-core/tests/tool_search_characterization_test.rs b/crates/agentic-server-core/tests/tool_search_characterization_test.rs new file mode 100644 index 00000000..3fd22fe5 --- /dev/null +++ b/crates/agentic-server-core/tests/tool_search_characterization_test.rs @@ -0,0 +1,1255 @@ +mod support; + +use std::collections::HashMap; +use std::fs; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +#[derive(Clone, Copy)] +enum Projection { + Public, + Normalized, +} + +#[derive(Debug, PartialEq, Eq)] +struct SemanticFlow { + execution: &'static str, + status: &'static str, + returned_tools: Value, + loaded_function_name: String, + function_output: Value, + final_text: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct RelevantCallLifecycle { + event_type: String, + status: Option, + arguments: Option, + execution: Option, + item_id: Option, + call_id: Option, +} + +const OPENAI_BLOCKING_CASSETTE: &str = "tool-search-openai-reference-gpt-5.6-nonstreaming.yaml"; +const OPENAI_STREAMING_CASSETTE: &str = "tool-search-openai-reference-gpt-5.6-streaming.yaml"; +const DIRECT_VLLM_BLOCKING_CASSETTE: &str = "tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml"; +const DIRECT_VLLM_STREAMING_CASSETTE: &str = "tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml"; +const GATEWAY_BLOCKING_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml"; +const GATEWAY_STREAMING_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml"; +const GATEWAY_WEBSOCKET_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml"; + +fn one_gateway_stream_cassette(directory: &Path, suffix: &str) -> PathBuf { + let matches = fs::read_dir(directory) + .expect("tool-search cassette directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name().and_then(|name| name.to_str()).is_some_and(|name| { + name.starts_with("tool-search-gateway-") + && !name.ends_with("-nonstreaming.yaml") + && name.ends_with(suffix) + }) + }) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "expected one recorder-generated gateway {suffix} cassette, found {matches:?}" + ); + matches.into_iter().next().expect("one gateway stream cassette") +} + +fn relevant_call_lifecycle_from_named_sse<'a>(lines: impl IntoIterator) -> Vec { + let mut call_ids_by_item_id = HashMap::::new(); + + support::named_sse_events(lines) + .into_iter() + .filter_map(|event| { + let item = event.get("item").and_then(Value::as_object); + let is_call_item = item.is_some_and(|item| { + matches!( + item.get("type").and_then(Value::as_str), + Some("tool_search_call" | "function_call" | "custom_tool_call") + ) + }); + if is_call_item { + let item_id = item.and_then(|item| item.get("id")).and_then(Value::as_str); + let call_id = item.and_then(|item| item.get("call_id")).and_then(Value::as_str); + if let (Some(item_id), Some(call_id)) = (item_id, call_id) { + call_ids_by_item_id.insert(item_id.to_string(), call_id.to_string()); + } + } + + let linked_item_id = event.get("item_id").and_then(Value::as_str); + if !is_call_item && !linked_item_id.is_some_and(|item_id| call_ids_by_item_id.contains_key(item_id)) { + return None; + } + + let status = item + .and_then(|item| item.get("status")) + .or_else(|| event.get("status")) + .and_then(Value::as_str) + .map(str::to_string); + let arguments = event + .get("arguments") + .or_else(|| item.and_then(|item| item.get("arguments"))) + .or_else(|| event.get("delta")) + .cloned(); + let execution = item + .and_then(|item| item.get("execution")) + .or_else(|| event.get("execution")) + .and_then(Value::as_str) + .map(str::to_string); + let call_id = item + .and_then(|item| item.get("call_id")) + .and_then(Value::as_str) + .or_else(|| event.get("call_id").and_then(Value::as_str)) + .map(str::to_string) + .or_else(|| linked_item_id.and_then(|item_id| call_ids_by_item_id.get(item_id).cloned())); + let item_id = item + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .or(linked_item_id) + .map(str::to_string); + + Some(RelevantCallLifecycle { + event_type: event["type"] + .as_str() + .expect("named SSE event should have a type") + .to_string(), + status, + arguments, + execution, + item_id, + call_id, + }) + }) + .collect() +} + +fn non_empty_call_id(item: &Value) -> &str { + let call_id = item["call_id"].as_str().expect("tool item should have a call_id"); + assert!(!call_id.trim().is_empty(), "tool item call_id should not be empty"); + call_id +} + +fn terminal_response(turn: &support::Turn) -> Value { + if let Some(body) = &turn.response.body { + return body.clone(); + } + support::recorded_named_sse_events(turn) + .into_iter() + .find(|event| event["type"] == "response.completed") + .and_then(|event| event.get("response").cloned()) + .expect("streaming characterization should contain response.completed") +} + +fn terminal_response_from_sse_chunks(chunks: &[&str]) -> Value { + support::named_sse_events(chunks.iter().flat_map(|chunk| chunk.lines())) + .into_iter() + .find(|event| event["type"] == "response.completed") + .and_then(|event| event.get("response").cloned()) + .expect("offline SSE should contain response.completed") +} + +fn assert_stream_completed(events: &[Value]) { + assert!( + events + .iter() + .all(|event| !matches!(event["type"].as_str(), Some("error" | "response.failed"))), + "recorded stream must not contain failure events" + ); + let completed = events + .iter() + .filter(|event| event["type"] == "response.completed") + .collect::>(); + assert_eq!(completed.len(), 1, "recorded stream should complete exactly once"); + assert_eq!(completed[0]["response"]["status"], "completed"); +} + +fn assert_observed_call_lifecycle(turn: &support::Turn) -> (String, String, Value) { + let events = support::recorded_named_sse_events(turn); + assert_stream_completed(&events); + + let lifecycle = relevant_call_lifecycle_from_named_sse( + turn.response + .sse + .as_ref() + .expect("streaming cassette should contain SSE") + .iter() + .flat_map(|entry| entry.lines()), + ); + assert!( + lifecycle.len() >= 4, + "call lifecycle should include added, deltas, and done events" + ); + assert_eq!(lifecycle[0].event_type, "response.output_item.added"); + assert_eq!( + lifecycle.last().expect("lifecycle should not be empty").event_type, + "response.output_item.done" + ); + assert_eq!( + lifecycle.last().and_then(|event| event.status.as_deref()), + Some("completed") + ); + + let arguments_done_indices = lifecycle + .iter() + .enumerate() + .filter_map(|(index, event)| (event.event_type == "response.function_call_arguments.done").then_some(index)) + .collect::>(); + assert_eq!( + arguments_done_indices.len(), + 1, + "call lifecycle should contain exactly one arguments.done event" + ); + let arguments_done_index = arguments_done_indices[0]; + assert!( + arguments_done_index > 1, + "call lifecycle should contain at least one argument delta" + ); + assert_eq!(arguments_done_index + 1, lifecycle.len() - 1); + assert!( + lifecycle[1..arguments_done_index] + .iter() + .all(|event| event.event_type == "response.function_call_arguments.delta"), + "only argument deltas should occur between output_item.added and arguments.done" + ); + + let aggregated_arguments = lifecycle[1..arguments_done_index] + .iter() + .map(|event| { + event + .arguments + .as_ref() + .and_then(Value::as_str) + .expect("argument delta should be text") + }) + .collect::(); + let done_arguments = lifecycle[arguments_done_index] + .arguments + .as_ref() + .and_then(Value::as_str) + .expect("arguments.done should contain final arguments"); + assert_eq!( + aggregated_arguments, done_arguments, + "aggregated deltas should be invariant to provider chunk boundaries" + ); + assert_eq!( + lifecycle.last().and_then(|event| event.arguments.as_ref()), + lifecycle[arguments_done_index].arguments.as_ref(), + "output_item.done should repeat the completed arguments" + ); + + let item_id = lifecycle[0] + .item_id + .as_deref() + .expect("output_item.added should contain an item ID"); + let call_id = lifecycle[0] + .call_id + .as_deref() + .expect("output_item.added should contain a call ID"); + assert!(!item_id.trim().is_empty()); + assert!(!call_id.trim().is_empty()); + assert!( + lifecycle.iter().all(|event| event.item_id.as_deref() == Some(item_id)), + "all call lifecycle events should link to one item ID" + ); + assert!( + lifecycle.iter().all(|event| event.call_id.as_deref() == Some(call_id)), + "all call lifecycle events should link to one call ID" + ); + + ( + item_id.to_string(), + call_id.to_string(), + serde_json::from_str(done_arguments).expect("completed arguments should be valid JSON"), + ) +} + +fn client_calls(output: &[Value]) -> Vec<&Value> { + output + .iter() + .filter(|item| { + matches!( + item["type"].as_str(), + Some("tool_search_call" | "function_call" | "custom_tool_call") + ) + }) + .collect() +} + +fn normalize_search_step(response: &Value, continuation: &Value, projection: Projection) -> Value { + let output = response["output"] + .as_array() + .expect("first response output should be an array"); + assert_eq!( + client_calls(output).len(), + 1, + "first response should contain exactly one client call" + ); + let search_calls = output + .iter() + .filter(|item| match projection { + Projection::Public => item["type"] == "tool_search_call", + Projection::Normalized => item["type"] == "function_call" && item["name"] == "tool_search", + }) + .collect::>(); + assert_eq!( + search_calls.len(), + 1, + "first response should contain exactly one search call" + ); + let search_call = search_calls[0]; + let search_call_id = non_empty_call_id(search_call); + assert_eq!(search_call["status"], "completed"); + match projection { + Projection::Public => { + assert_eq!(search_call["execution"], "client"); + assert!(search_call["arguments"].as_object().is_some_and(|arguments| { + arguments + .get("query") + .and_then(Value::as_str) + .is_some_and(|query| !query.is_empty()) + })); + } + Projection::Normalized => { + let arguments = search_call["arguments"] + .as_str() + .and_then(|arguments| serde_json::from_str::(arguments).ok()) + .expect("normalized search arguments should be valid JSON"); + assert!( + arguments["query"].as_str().is_some_and(|query| !query.is_empty()), + "normalized search arguments should contain a query" + ); + } + } + + let search_outputs = continuation + .as_array() + .expect("search continuation should be an input array") + .iter() + .filter(|item| { + item["call_id"] == search_call_id + && match projection { + Projection::Public => item["type"] == "tool_search_output", + Projection::Normalized => item["type"] == "function_call_output", + } + }) + .collect::>(); + assert_eq!( + search_outputs.len(), + 1, + "exactly one search output should link to the search call" + ); + let search_output = search_outputs[0]; + match projection { + Projection::Public => { + assert_eq!(search_output["execution"], "client"); + assert_eq!(search_output["status"], "completed"); + search_output["tools"].clone() + } + Projection::Normalized => { + let output = search_output["output"] + .as_str() + .expect("normalized search output should be JSON text"); + let decoded = serde_json::from_str::(output).expect("normalized search output should be valid JSON"); + assert_eq!( + output, + serde_json::to_string(&decoded).expect("normalized search output should serialize canonically"), + "normalized search output should use canonical compact JSON" + ); + decoded["tools"].clone() + } + } +} + +fn normalize_loaded_step(response: &Value, continuation: &Value, returned_tools: &Value) -> (String, Value) { + let output = response["output"] + .as_array() + .expect("second response output should be an array"); + assert_eq!( + client_calls(output).len(), + 1, + "second response should contain exactly one client call" + ); + let loaded_calls = output + .iter() + .filter(|item| item["type"] == "function_call" && item["name"] != "tool_search") + .collect::>(); + assert_eq!( + loaded_calls.len(), + 1, + "second response should call exactly one loaded function" + ); + let loaded_call = loaded_calls[0]; + let loaded_call_id = non_empty_call_id(loaded_call); + assert_eq!(loaded_call["status"], "completed"); + let loaded_function_name = loaded_call["name"] + .as_str() + .expect("loaded function call should have a name") + .to_string(); + assert!( + returned_tools + .as_array() + .expect("search output tools should be an array") + .iter() + .any(|tool| tool["name"].as_str() == Some(&loaded_function_name)), + "called function should come from the search output" + ); + let loaded_arguments = loaded_call["arguments"] + .as_str() + .and_then(|arguments| serde_json::from_str::(arguments).ok()) + .expect("loaded function arguments should be valid JSON"); + assert_eq!(loaded_arguments, serde_json::json!({"city": "Paris"})); + + let function_outputs = continuation + .as_array() + .expect("function continuation should be an input array") + .iter() + .filter(|item| item["type"] == "function_call_output" && item["call_id"] == loaded_call_id) + .collect::>(); + assert_eq!( + function_outputs.len(), + 1, + "exactly one function output should link to the loaded call" + ); + (loaded_function_name, function_outputs[0]["output"].clone()) +} + +fn normalized_final_text(response: &Value) -> String { + let output = response["output"] + .as_array() + .expect("final response output should be an array"); + assert!( + client_calls(output).is_empty(), + "final response must not contain tool calls" + ); + output + .iter() + .filter(|item| item["type"] == "message") + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .filter(|part| part["type"] == "output_text") + .filter_map(|part| part["text"].as_str()) + .collect::() + .trim() + .to_owned() +} + +fn normalize_flow(responses: &[Value], continuation_inputs: &[Value], projection: Projection) -> SemanticFlow { + assert_eq!(responses.len(), 3, "tool-search characterization needs three responses"); + assert_eq!( + continuation_inputs.len(), + 2, + "tool-search characterization needs two continuations" + ); + let returned_tools = normalize_search_step(&responses[0], &continuation_inputs[0], projection); + let (loaded_function_name, function_output) = + normalize_loaded_step(&responses[1], &continuation_inputs[1], &returned_tools); + SemanticFlow { + execution: "client", + status: "completed", + returned_tools, + loaded_function_name, + function_output, + final_text: normalized_final_text(&responses[2]), + } +} + +fn weather_tool_definition() -> Value { + serde_json::json!([{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }]) +} + +fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { + let responses = vec![ + serde_json::json!({ + "id": "resp_public_search", + "created_at": 10, + "usage": {"total_tokens": 50}, + "output": [{ + "id": "tsc_public", + "type": "tool_search_call", + "call_id": "call_public_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather tool"} + }] + }), + serde_json::json!({ + "id": "resp_public_function", + "reasoning": {"summary": "provider noise"}, + "output": [{ + "id": "fc_public", + "type": "function_call", + "name": "get_weather", + "call_id": "call_public_function", + "status": "completed", + "arguments": "{\"city\":\"Paris\"}" + }] + }), + serde_json::json!({ + "id": "resp_public_final", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "\n\nPARIS_WEATHER_OK"}] + }] + }), + ]; + let inputs = vec![ + serde_json::json!([{ + "type": "tool_search_output", + "call_id": "call_public_search", + "execution": "client", + "status": "completed", + "tools": returned_tools + }]), + serde_json::json!([{ + "type": "function_call_output", + "call_id": "call_public_function", + "output": "weather result" + }]), + ]; + (responses, inputs) +} + +fn normalized_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { + let responses = vec![ + serde_json::json!({ + "id": "resp_vllm_search", + "created_at": 999, + "usage": null, + "output": [{ + "id": "fc_vllm_search", + "type": "function_call", + "name": "tool_search", + "call_id": "call_vllm_search", + "status": "completed", + "arguments": "{\"query\":\"weather tool\"}" + }] + }), + serde_json::json!({ + "id": "resp_vllm_function", + "output": [{ + "id": "fc_vllm", + "type": "function_call", + "name": "get_weather", + "call_id": "call_vllm_function", + "status": "completed", + "arguments": "{\"city\":\"Paris\"}" + }] + }), + serde_json::json!({ + "id": "resp_vllm_final", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "PARIS_WEATHER_OK"}] + }] + }), + ]; + let inputs = vec![ + serde_json::json!([{ + "type": "function_call_output", + "call_id": "call_vllm_search", + "output": serde_json::to_string(&serde_json::json!({"tools": returned_tools})).unwrap() + }]), + serde_json::json!([{ + "type": "function_call_output", + "call_id": "call_vllm_function", + "output": "weather result" + }]), + ]; + (responses, inputs) +} + +fn normalized_manual_inputs(responses: &[Value], inputs: &[Value]) -> Vec { + vec![ + serde_json::json!([ + {"type": "message", "role": "user", "content": "find a weather tool"}, + responses[0]["output"][0].clone(), + inputs[0][0].clone(), + {"type": "message", "role": "user", "content": "call it"} + ]), + serde_json::json!([ + {"type": "message", "role": "user", "content": "find a weather tool"}, + responses[0]["output"][0].clone(), + inputs[0][0].clone(), + {"type": "message", "role": "user", "content": "call it"}, + responses[1]["output"][0].clone(), + inputs[1][0].clone(), + {"type": "message", "role": "user", "content": "finish"} + ]), + ] +} + +fn assert_semantic_mutations_are_visible( + public: &SemanticFlow, + public_responses: &[Value], + public_inputs: &[Value], + normalized_responses: &[Value], + normalized_inputs: &[Value], +) { + let mut schema_mutation = normalized_inputs.to_vec(); + let mut decoded = serde_json::from_str::(schema_mutation[0][0]["output"].as_str().unwrap()).unwrap(); + decoded["tools"][0]["parameters"]["properties"]["city"]["type"] = Value::String("number".to_string()); + schema_mutation[0][0]["output"] = Value::String(serde_json::to_string(&decoded).unwrap()); + let mutated = normalize_flow(normalized_responses, &schema_mutation, Projection::Normalized); + assert_ne!(public, &mutated, "schema mutations must remain semantically visible"); + + let mut function_output_mutation = normalized_inputs.to_vec(); + function_output_mutation[1][0]["output"] = Value::String("different weather".to_string()); + let mutated = normalize_flow(normalized_responses, &function_output_mutation, Projection::Normalized); + assert_ne!( + public, &mutated, + "client function-output mutations must remain semantically visible" + ); + + let mut status_mutation = public_responses.to_vec(); + status_mutation[0]["output"][0]["status"] = Value::String("in_progress".to_string()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + normalize_flow(&status_mutation, public_inputs, Projection::Public) + })) + .is_err(), + "public status mutation should be rejected" + ); + + for (output_index, label) in [(0, "normalized search"), (1, "normalized loaded function")] { + let mut status_mutation = normalized_responses.to_vec(); + status_mutation[output_index]["output"][0]["status"] = Value::String("in_progress".to_string()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + normalize_flow(&status_mutation, normalized_inputs, Projection::Normalized) + })) + .is_err(), + "{label} status mutation should be rejected" + ); + } + + let mut linkage_mutation = public_inputs.to_vec(); + linkage_mutation[0][0]["call_id"] = Value::String("call_wrong".to_string()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + normalize_flow(public_responses, &linkage_mutation, Projection::Public) + })) + .is_err(), + "call linkage mutation should be rejected" + ); +} + +#[test] +fn raw_semantic_normalization_ignores_provider_ids_usage_and_wire_projection() { + let returned_tools = weather_tool_definition(); + let (public_responses, public_inputs) = public_semantic_fixture(&returned_tools); + let (normalized_responses, normalized_inputs) = normalized_semantic_fixture(&returned_tools); + let public = normalize_flow(&public_responses, &public_inputs, Projection::Public); + let normalized = normalize_flow(&normalized_responses, &normalized_inputs, Projection::Normalized); + assert_eq!(public, normalized); + + assert_eq!( + normalize_flow( + &normalized_responses, + &normalized_manual_inputs(&normalized_responses, &normalized_inputs), + Projection::Normalized, + ), + public, + "manual full-history replay should normalize to the same semantics" + ); + assert_semantic_mutations_are_visible( + &public, + &public_responses, + &public_inputs, + &normalized_responses, + &normalized_inputs, + ); +} + +#[test] +fn offline_sse_terminal_normalization_ignores_event_chunk_grouping() { + let completed = serde_json::json!({ + "type": "response.completed", + "response": { + "id": "resp_terminal", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "PARIS_WEATHER_OK"}] + }] + } + }); + let completed_data = format!("data: {completed}"); + let grouped = format!( + "event: response.created\ndata: {{\"type\":\"response.created\"}}\nevent: response.completed\n{completed_data}\n" + ); + let split = [ + "event: response.created", + "data: {\"type\":\"response.created\"}", + "event: response.completed", + completed_data.as_str(), + ]; + + assert_eq!( + terminal_response_from_sse_chunks(&[grouped.as_str()]), + terminal_response_from_sse_chunks(&split) + ); +} + +#[test] +fn named_sse_call_lifecycle_projection_preserves_order_and_linkage() { + let sse = [ + "event: response.output_item.added", + r#"data: {"type":"response.output_item.added","item":{"id":"reasoning_1","type":"reasoning","status":"in_progress"}}"#, + "event: response.output_item.added", + r#"data: {"type":"response.output_item.added","item":{"id":"function_1","type":"function_call","name":"fixture_call","call_id":"call_1","status":"in_progress","arguments":""}}"#, + "event: response.function_call_arguments.delta", + r#"data: {"type":"response.function_call_arguments.delta","item_id":"function_1","delta":"{\"city\":"}"#, + "event: response.function_call_arguments.done", + r#"data: {"type":"response.function_call_arguments.done","item_id":"function_1","arguments":"{\"city\":\"Paris\"}"}"#, + "event: response.output_item.done", + r#"data: {"type":"response.output_item.done","item":{"id":"function_1","type":"function_call","name":"fixture_call","call_id":"call_1","status":"completed","arguments":"{\"city\":\"Paris\"}"}}"#, + ]; + + assert_eq!( + relevant_call_lifecycle_from_named_sse(sse), + vec![ + RelevantCallLifecycle { + event_type: "response.output_item.added".to_string(), + status: Some("in_progress".to_string()), + arguments: Some(Value::String(String::new())), + execution: None, + item_id: Some("function_1".to_string()), + call_id: Some("call_1".to_string()), + }, + RelevantCallLifecycle { + event_type: "response.function_call_arguments.delta".to_string(), + status: None, + arguments: Some(Value::String("{\"city\":".to_string())), + execution: None, + item_id: Some("function_1".to_string()), + call_id: Some("call_1".to_string()), + }, + RelevantCallLifecycle { + event_type: "response.function_call_arguments.done".to_string(), + status: None, + arguments: Some(Value::String("{\"city\":\"Paris\"}".to_string())), + execution: None, + item_id: Some("function_1".to_string()), + call_id: Some("call_1".to_string()), + }, + RelevantCallLifecycle { + event_type: "response.output_item.done".to_string(), + status: Some("completed".to_string()), + arguments: Some(Value::String("{\"city\":\"Paris\"}".to_string())), + execution: None, + item_id: Some("function_1".to_string()), + call_id: Some("call_1".to_string()), + }, + ] + ); +} + +#[test] +fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); + let blocking = support::load_cassette( + directory + .join(GATEWAY_BLOCKING_CASSETTE) + .to_str() + .expect("blocking gateway cassette path"), + ); + let blocking_responses = blocking.turns.iter().map(terminal_response).collect::>(); + let blocking_inputs = blocking.turns[1..] + .iter() + .map(|turn| turn.request.body.input.clone()) + .collect::>(); + let blocking_flow = normalize_flow(&blocking_responses, &blocking_inputs, Projection::Public); + + for suffix in ["-streaming.yaml", "-websocket.yaml"] { + let path = one_gateway_stream_cassette(&directory, suffix); + let cassette = support::load_cassette(path.to_str().expect("gateway stream cassette path")); + assert_eq!(cassette.turns.len(), 3); + + for turn in &cassette.turns { + let events = support::recorded_named_sse_events(turn); + assert_eq!( + events + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(events.len()).unwrap()).map(Some).collect::>() + ); + assert!( + events + .iter() + .all(|event| event["type"] != "error" && event["type"] != "response.failed") + ); + assert!(events.iter().all(|event| { + event["response"]["tools"].as_array().is_none_or(|tools| { + tools + .iter() + .all(|tool| !(tool["type"] == "function" && tool["name"] == "tool_search")) + }) + })); + } + + let first_events = support::recorded_named_sse_events(&cassette.turns[0]); + assert!(first_events.iter().any(|event| { + event["response"]["tools"].as_array().is_some_and(|tools| { + tools + .iter() + .any(|tool| tool["type"] == "tool_search" && tool["execution"] == "client") + && tools + .iter() + .any(|tool| tool["name"] == "get_weather" && tool["defer_loading"] == true) + }) + })); + assert!(first_events.iter().all(|event| { + !(matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) || matches!( + event["type"].as_str(), + Some("response.output_item.added" | "response.output_item.done") + ) && event["item"]["type"] == "function_call" + && event["item"]["name"] == "tool_search") + })); + let lifecycle = first_events + .iter() + .filter(|event| { + matches!( + event["type"].as_str(), + Some("response.output_item.added" | "response.output_item.done") + ) && event["item"]["type"] == "tool_search_call" + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["item"]["status"], "in_progress"); + assert_eq!(lifecycle[0]["item"]["arguments"], serde_json::json!({})); + assert_eq!(lifecycle[1]["item"]["status"], "completed"); + assert_eq!(lifecycle[0]["item"]["id"], lifecycle[1]["item"]["id"]); + assert_eq!(lifecycle[0]["item"]["call_id"], lifecycle[1]["item"]["call_id"]); + assert_eq!(lifecycle[0]["output_index"], lifecycle[1]["output_index"]); + + let responses = cassette.turns.iter().map(terminal_response).collect::>(); + let terminal_search = responses[0]["output"] + .as_array() + .expect("first gateway output") + .iter() + .find(|item| item["type"] == "tool_search_call") + .expect("terminal public search call"); + assert_eq!(terminal_search, &lifecycle[1]["item"]); + let inputs = cassette.turns[1..] + .iter() + .map(|turn| turn.request.body.input.clone()) + .collect::>(); + assert_eq!(normalize_flow(&responses, &inputs, Projection::Public), blocking_flow); + } +} + +#[test] +fn openai_streaming_cassette_preserves_public_lifecycle_and_terminal_identity() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/cassettes/tool_search") + .join(OPENAI_STREAMING_CASSETTE); + let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); + assert_eq!(cassette.turns.len(), 3); + + let search_events = support::recorded_named_sse_events(&cassette.turns[0]); + assert!( + search_events + .iter() + .all(|event| !matches!(event["type"].as_str(), Some("error" | "response.failed"))) + ); + let search_lifecycle = relevant_call_lifecycle_from_named_sse( + cassette.turns[0] + .response + .sse + .as_ref() + .expect("streaming cassette should contain SSE") + .iter() + .flat_map(|entry| entry.lines()), + ); + assert_eq!( + search_lifecycle + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(), + ["response.output_item.added", "response.output_item.done"] + ); + assert_eq!(search_lifecycle[0].status.as_deref(), Some("in_progress")); + assert_eq!(search_lifecycle[1].status.as_deref(), Some("completed")); + assert_eq!(search_lifecycle[0].execution.as_deref(), Some("client")); + assert_eq!(search_lifecycle[1].execution.as_deref(), Some("client")); + assert_eq!(search_lifecycle[0].arguments, Some(serde_json::json!({}))); + assert!( + search_lifecycle[1] + .arguments + .as_ref() + .and_then(|arguments| arguments["query"].as_str()) + .is_some_and(|query| !query.trim().is_empty()) + ); + assert_eq!(search_lifecycle[0].item_id, search_lifecycle[1].item_id); + assert_eq!(search_lifecycle[0].call_id, search_lifecycle[1].call_id); + + let (loaded_item_id, loaded_call_id, loaded_arguments) = assert_observed_call_lifecycle(&cassette.turns[1]); + assert_eq!(loaded_arguments, serde_json::json!({"city": "Paris"})); + + let responses = cassette.turns.iter().map(terminal_response).collect::>(); + let terminal_search_call = responses[0]["output"] + .as_array() + .expect("turn one terminal output should be an array") + .iter() + .find(|item| item["type"] == "tool_search_call") + .expect("turn one terminal response should contain tool_search_call"); + assert_eq!( + terminal_search_call["id"].as_str(), + search_lifecycle[1].item_id.as_deref(), + "OpenAI preserves the search item ID into terminal output" + ); + assert_eq!( + terminal_search_call["call_id"].as_str(), + search_lifecycle[1].call_id.as_deref(), + "OpenAI preserves the search call ID into terminal output" + ); + assert_eq!(terminal_search_call["execution"], "client"); + assert_eq!(terminal_search_call["status"], "completed"); + assert_eq!( + Some(&terminal_search_call["arguments"]), + search_lifecycle[1].arguments.as_ref() + ); + + let terminal_loaded_call = responses[1]["output"] + .as_array() + .expect("turn two terminal output should be an array") + .iter() + .find(|item| item["type"] == "function_call" && item["name"] == "get_weather") + .expect("turn two terminal response should contain get_weather"); + assert_eq!(terminal_loaded_call["id"].as_str(), Some(loaded_item_id.as_str())); + assert_eq!(terminal_loaded_call["call_id"].as_str(), Some(loaded_call_id.as_str())); + + let final_events = support::recorded_named_sse_events(&cassette.turns[2]); + assert!( + final_events + .iter() + .all(|event| !matches!(event["type"].as_str(), Some("error" | "response.failed"))) + ); + let continuation_inputs = cassette.turns[1..] + .iter() + .map(|turn| turn.request.body.input.clone()) + .collect::>(); + let semantic = normalize_flow(&responses, &continuation_inputs, Projection::Public); + assert_eq!(semantic.loaded_function_name, "get_weather"); + assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); +} + +#[test] +fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_mismatch() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/cassettes/tool_search") + .join(DIRECT_VLLM_STREAMING_CASSETTE); + let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); + assert_eq!(cassette.turns.len(), 3); + + let (search_lifecycle_item_id, search_lifecycle_call_id, search_arguments) = + assert_observed_call_lifecycle(&cassette.turns[0]); + assert!( + search_arguments["query"] + .as_str() + .is_some_and(|query| !query.trim().is_empty()) + ); + let (loaded_lifecycle_item_id, loaded_lifecycle_call_id, loaded_arguments) = + assert_observed_call_lifecycle(&cassette.turns[1]); + assert_eq!(loaded_arguments, serde_json::json!({"city": "Paris"})); + + let final_events = support::recorded_named_sse_events(&cassette.turns[2]); + assert!( + final_events + .iter() + .all(|event| !matches!(event["type"].as_str(), Some("error" | "response.failed"))) + ); + let final_completed = final_events + .iter() + .filter(|event| event["type"] == "response.completed") + .collect::>(); + assert_eq!(final_completed.len(), 1); + assert_eq!(final_completed[0]["response"]["status"], "completed"); + let final_lifecycle = relevant_call_lifecycle_from_named_sse( + cassette.turns[2] + .response + .sse + .as_ref() + .expect("streaming cassette should contain SSE") + .iter() + .flat_map(|entry| entry.lines()), + ); + assert!( + final_lifecycle.is_empty(), + "final turn must not contain a client call lifecycle" + ); + + let responses = cassette.turns.iter().map(terminal_response).collect::>(); + let terminal_search_call = responses[0]["output"] + .as_array() + .expect("turn one terminal output should be an array") + .iter() + .find(|item| item["type"] == "function_call" && item["name"] == "tool_search") + .expect("turn one terminal response should contain tool_search"); + let terminal_loaded_call = responses[1]["output"] + .as_array() + .expect("turn two terminal output should be an array") + .iter() + .find(|item| item["type"] == "function_call" && item["name"] == "get_weather") + .expect("turn two terminal response should contain get_weather"); + assert_ne!( + terminal_search_call["id"].as_str(), + Some(search_lifecycle_item_id.as_str()), + "observed vLLM stream regenerates the terminal search item ID" + ); + assert_ne!( + terminal_search_call["call_id"].as_str(), + Some(search_lifecycle_call_id.as_str()), + "observed vLLM stream regenerates the terminal search call ID" + ); + assert_ne!( + terminal_loaded_call["id"].as_str(), + Some(loaded_lifecycle_item_id.as_str()), + "observed vLLM stream regenerates the terminal loaded-function item ID" + ); + assert_ne!( + terminal_loaded_call["call_id"].as_str(), + Some(loaded_lifecycle_call_id.as_str()), + "observed vLLM stream regenerates the terminal loaded-function call ID" + ); + + let continuation_inputs = cassette.turns[1..] + .iter() + .map(|turn| turn.request.body.input.clone()) + .collect::>(); + let semantic = normalize_flow(&responses, &continuation_inputs, Projection::Normalized); + assert_eq!(semantic.loaded_function_name, "get_weather"); + assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); +} + +const PROVIDER_PARITY_CASSETTES: [&str; 7] = [ + OPENAI_BLOCKING_CASSETTE, + OPENAI_STREAMING_CASSETTE, + DIRECT_VLLM_BLOCKING_CASSETTE, + DIRECT_VLLM_STREAMING_CASSETTE, + GATEWAY_BLOCKING_CASSETTE, + GATEWAY_STREAMING_CASSETTE, + GATEWAY_WEBSOCKET_CASSETTE, +]; + +fn provider_projection(filename: &str) -> Projection { + if filename.contains("openai-reference") || filename.contains("gateway") { + Projection::Public + } else if filename.contains("direct-vllm") { + Projection::Normalized + } else { + panic!("unexpected tool-search characterization cassette: {filename}"); + } +} + +#[cfg(unix)] +fn assert_recorder_cassette_mode(path: &Path, filename: &str) { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(path) + .expect("characterization cassette metadata") + .permissions() + .mode() + & 0o777, + 0o664, + "recorder-generated cassette mode drift in {filename}" + ); +} + +#[cfg(not(unix))] +fn assert_recorder_cassette_mode(_path: &Path, _filename: &str) {} + +fn assert_public_request_projection( + directory: &Path, + filename: &str, + request_bodies: &[&serde_json::Map], + responses: &[Value], +) { + let expected_initial = serde_json::from_str::( + &fs::read_to_string(directory.join("openai_tools.json")).expect("OpenAI tool fixture should be readable"), + ) + .expect("OpenAI tool fixture should be valid JSON"); + assert_eq!(request_bodies[0].get("tools"), Some(&expected_initial)); + if filename == GATEWAY_BLOCKING_CASSETTE { + assert!( + request_bodies + .iter() + .all(|body| body.get("store") == Some(&Value::Bool(false))) + ); + assert!( + request_bodies + .iter() + .all(|body| !body.contains_key("previous_response_id")) + ); + } else { + assert!( + request_bodies + .iter() + .all(|body| body.get("store") == Some(&Value::Bool(true))) + ); + assert_eq!(request_bodies[1].get("previous_response_id"), responses[0].get("id")); + assert_eq!(request_bodies[2].get("previous_response_id"), responses[1].get("id")); + } + assert!(!request_bodies[1].contains_key("tools")); + assert!(!request_bodies[2].contains_key("tools")); +} + +fn assert_normalized_request_projection( + directory: &Path, + request_bodies: &[&serde_json::Map], + responses: &[Value], +) { + let expected_initial = serde_json::from_str::( + &fs::read_to_string(directory.join("vllm_initial_tools.json")) + .expect("vLLM initial tool fixture should be readable"), + ) + .expect("vLLM initial tool fixture should be valid JSON"); + let expected_next = serde_json::from_str::( + &fs::read_to_string(directory.join("vllm_tools_after_search.json")) + .expect("vLLM post-search tool fixture should be readable"), + ) + .expect("vLLM post-search tool fixture should be valid JSON"); + assert_eq!(request_bodies[0].get("tools"), Some(&expected_initial)); + assert_eq!(request_bodies[1].get("tools"), Some(&expected_next)); + assert_eq!(request_bodies[2].get("tools"), Some(&expected_next)); + assert!( + request_bodies + .iter() + .all(|body| body.get("store") == Some(&Value::Bool(false))) + ); + assert!( + request_bodies + .iter() + .all(|body| !body.contains_key("previous_response_id")) + ); + + let inputs = request_bodies + .iter() + .map(|body| { + body["input"] + .as_array() + .expect("manual replay input should be an array") + }) + .collect::>(); + let mut expected_second_prefix = inputs[0].clone(); + expected_second_prefix.extend( + responses[0]["output"] + .as_array() + .expect("turn one output should be an array") + .iter() + .cloned(), + ); + assert_eq!( + &inputs[1][..expected_second_prefix.len()], + expected_second_prefix.as_slice() + ); + let mut expected_third_prefix = inputs[1].clone(); + expected_third_prefix.extend( + responses[1]["output"] + .as_array() + .expect("turn two output should be an array") + .iter() + .cloned(), + ); + assert_eq!( + &inputs[2][..expected_third_prefix.len()], + expected_third_prefix.as_slice() + ); +} + +fn assert_request_projection( + directory: &Path, + filename: &str, + raw_document: &Value, + responses: &[Value], + projection: Projection, +) { + let request_bodies = raw_document["turns"] + .as_array() + .expect("raw cassette should contain turns") + .iter() + .map(|turn| { + turn["request"]["body"] + .as_object() + .expect("recorded request body should be an object") + }) + .collect::>(); + match projection { + Projection::Public => assert_public_request_projection(directory, filename, &request_bodies, responses), + Projection::Normalized => assert_normalized_request_projection(directory, &request_bodies, responses), + } +} + +fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow { + let path = directory.join(filename); + assert!( + path.is_file(), + "required provider parity cassette is missing: {filename}" + ); + assert_recorder_cassette_mode(&path, filename); + let raw_text = fs::read_to_string(&path).expect("characterization cassette should be readable"); + let raw_document = serde_yaml::from_str::(&raw_text).expect("characterization YAML should be valid"); + let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); + assert_eq!(cassette.turns.len(), 3, "{filename} should contain three turns"); + let responses = cassette.turns.iter().map(terminal_response).collect::>(); + let inputs = cassette.turns[1..] + .iter() + .map(|turn| turn.request.body.input.clone()) + .collect::>(); + let projection = provider_projection(filename); + assert_request_projection(directory, filename, &raw_document, &responses, projection); + normalize_flow(&responses, &inputs, projection) +} + +#[test] +fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); + let expected_tools = serde_json::from_str::( + &fs::read_to_string(directory.join("returned_tools.json")).expect("returned tool fixture should be readable"), + ) + .expect("returned tool fixture should be valid JSON"); + let expected_outputs = serde_json::from_str::( + &fs::read_to_string(directory.join("function_outputs.json")) + .expect("function-output fixture should be readable"), + ) + .expect("function-output fixture should be valid JSON"); + let mut reference = None; + for filename in PROVIDER_PARITY_CASSETTES { + let semantic = normalize_provider_cassette(&directory, filename); + assert_eq!( + semantic.returned_tools, expected_tools, + "returned tool drift in {filename}" + ); + assert_eq!( + semantic.function_output, expected_outputs["get_weather"], + "client function-output drift in {filename}" + ); + assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); + if let Some(expected) = &reference { + assert_eq!(&semantic, expected, "semantic provider drift in {filename}"); + } else { + reference = Some(semantic); + } + } +} diff --git a/crates/agentic-server-core/tests/tool_search_state_test.rs b/crates/agentic-server-core/tests/tool_search_state_test.rs new file mode 100644 index 00000000..63daaeae --- /dev/null +++ b/crates/agentic-server-core/tests/tool_search_state_test.rs @@ -0,0 +1,1523 @@ +use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; +use agentic_core::{InputItem, RequestPayload, ResponsesInput}; +use serde_json::{Value, json}; + +fn request(tools: Value, input: Value) -> RequestPayload { + let mut value = json!({ + "model": "test-model", + "store": false, + "parallel_tool_calls": false + }); + value["input"] = input; + value["tools"] = tools; + serde_json::from_value(value).expect("test request must match the public wire model") +} + +fn search_declaration() -> Value { + json!({ + "type": "tool_search", + "execution": "client", + "description": "Search the client tool catalog", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": false + } + }) +} + +fn function(name: &str, description: &str, city_type: &str, deferred: bool) -> Value { + json!({ + "type": "function", + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": {"city": {"type": city_type}}, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": deferred + }) +} + +fn search_call(id: &str) -> Value { + json!({ + "type": "tool_search_call", + "id": format!("tsc_{id}"), + "call_id": id, + "arguments": {"query": "weather"} + }) +} + +fn search_output(id: &str, tools: Vec) -> Value { + let mut value = json!({ + "type": "tool_search_output", + "call_id": id + }); + value["tools"] = Value::Array(tools); + value +} + +fn tool_values(tools: Option<&[agentic_core::ResponsesTool]>) -> Value { + serde_json::to_value(tools).expect("prepared tools serialize") +} + +fn private_request(state: &ToolSearchState, public: &RequestPayload) -> RequestPayload { + state + .private_inference_request(public) + .expect("prepared state materializes a private inference request") +} + +fn private_tool_values(state: &ToolSearchState, public: &RequestPayload) -> Value { + let private = private_request(state, public); + tool_values(private.tools.as_deref()) +} + +fn private_input_value(state: &ToolSearchState, public: &RequestPayload) -> Value { + serde_json::to_value(private_request(state, public).input).expect("private input serializes") +} + +fn synthetic_description(state: &ToolSearchState) -> &str { + state + .synthetic_function() + .and_then(|function| function.description.as_deref()) + .expect("active tool search has a synthetic description") +} + +#[test] +fn fresh_and_sequential_state_has_distinct_deterministic_views() { + let deferred = function("get_weather", "Get weather", "string", true); + let dynamic = function("get_uv", "Get UV index", "string", true); + let tools = json!([ + search_declaration(), + { + "type": "function", + "name": "current_time", + "description": "Get current time", + "parameters": {"type": "object"} + }, + deferred + ]); + let input = json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![dynamic.clone()]), + search_call("call_search_2"), + search_output("call_search_2", vec![dynamic.clone()]) + ]); + let request = request(tools, input); + + let state = ToolSearchState::build(&request).expect("valid ordered history"); + let rebuilt = ToolSearchState::build(&request).expect("same request builds again"); + + assert!(state.is_active()); + assert_eq!( + serde_json::to_string(&( + tool_values(state.public_effective_tools()), + private_tool_values(&state, &request), + tool_values(Some(state.loaded_public_tools())), + serde_json::to_value(state.synthetic_function()).expect("synthetic declaration serializes") + )) + .expect("state snapshot serializes"), + serde_json::to_string(&( + tool_values(rebuilt.public_effective_tools()), + private_tool_values(&rebuilt, &request), + tool_values(Some(rebuilt.loaded_public_tools())), + serde_json::to_value(rebuilt.synthetic_function()).expect("synthetic declaration serializes") + )) + .expect("rebuilt snapshot serializes") + ); + + let public = tool_values(state.public_effective_tools()); + assert_eq!(public.as_array().map(Vec::len), Some(4)); + assert_eq!(public[2]["defer_loading"], true, "public deferral must be preserved"); + assert_eq!( + public[3], dynamic, + "a dynamic definition absent initially is appended once" + ); + + let loaded = tool_values(Some(state.loaded_public_tools())); + assert_eq!(loaded, json!([dynamic]), "an exact repeated definition is idempotent"); + + let private = private_tool_values(&state, &request); + assert_eq!(private.as_array().map(Vec::len), Some(3)); + assert_eq!(private[0]["type"], "function"); + assert_eq!(private[0]["name"], "tool_search"); + assert_eq!(private[1]["name"], "current_time"); + assert_eq!(private[2]["name"], "get_uv"); + assert!(private[2].get("defer_loading").is_none()); + assert!( + private + .as_array() + .expect("private tools") + .iter() + .all(|tool| tool["name"] != "get_weather"), + "unloaded deferred schemas must not enter the private view" + ); + + assert_eq!( + serde_json::to_value(state.synthetic_function()).expect("synthetic declaration serializes"), + json!({ + "type": "function", + "name": "tool_search", + "description": "Search the client tool catalog. Available catalog entry: get_weather — Get weather.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": false + }, + "strict": true + }) + ); +} + +#[test] +fn one_history_pass_prepares_canonical_private_input_without_mutating_public_input() { + let returned = function("get_weather", "Get weather", "string", true); + let request = request( + json!([search_declaration()]), + json!([ + {"role": "user", "content": "find weather"}, + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"z": 2, "a": 1} + }, + search_output("call_search_1", vec![returned.clone()]) + ]), + ); + let public_before = serde_json::to_value(&request.input).expect("public input serializes"); + + let state = ToolSearchState::build(&request).expect("matching history prepares once"); + + assert_eq!( + serde_json::to_value(&request.input).expect("public input remains serializable"), + public_before, + "the public request must not be rewritten" + ); + assert_eq!( + private_input_value(&state, &request), + json!([ + {"type": "message", "role": "user", "content": "find weather"}, + { + "type": "function_call", + "id": "tsc_1", + "call_id": "call_search_1", + "name": "tool_search", + "arguments": "{\"a\":1,\"z\":2}", + "status": "completed" + }, + { + "type": "function_call_output", + "call_id": "call_search_1", + "output": format!("{{\"tools\":[{}]}}", serde_json::to_string(&returned).unwrap()) + } + ]) + ); +} + +#[test] +fn ordinary_request_builds_inactive_state_without_loaded_tools() { + let request = request( + json!([{ + "type": "function", + "name": "ordinary", + "parameters": {"type": "object", "properties": {"secret_schema": {"type": "string"}}} + }]), + json!("hello"), + ); + + let state = ToolSearchState::build(&request).expect("ordinary request prepares inactive state"); + + assert!(!state.is_active()); + assert!(state.public_effective_tools().is_none()); + assert!(state.loaded_public_tools().is_empty()); +} + +#[test] +fn invalid_history_order_and_linkage_are_rejected() { + let deferred = function("get_weather", "Get weather", "string", true); + let base_tools = json!([search_declaration(), deferred.clone()]); + let mut blank_call = request(base_tools.clone(), json!([search_call("call_search_1")])); + let ResponsesInput::Items(items) = &mut blank_call.input else { + panic!("test request uses item input") + }; + let InputItem::ToolSearchCall(call) = &mut items[0] else { + panic!("test request starts with search call") + }; + call.call_id.clear(); + let mut blank_output = request( + base_tools.clone(), + json!([search_call("call_search_1"), search_output("call_search_1", vec![])]), + ); + let ResponsesInput::Items(items) = &mut blank_output.input else { + panic!("test request uses item input") + }; + let InputItem::ToolSearchOutput(output) = &mut items[1] else { + panic!("test request ends with search output") + }; + output.call_id.clear(); + + let cases = [ + ( + "orphan output", + request(base_tools.clone(), json!([search_output("call_search_1", vec![])])), + ), + ( + "output before call", + request( + base_tools.clone(), + json!([search_output("call_search_1", vec![]), search_call("call_search_1")]), + ), + ), + ( + "mismatched call id", + request( + base_tools.clone(), + json!([search_call("call_search_1"), search_output("call_search_2", vec![])]), + ), + ), + ( + "ambiguous nested call", + request( + base_tools.clone(), + json!([search_call("call_search_1"), search_call("call_search_2")]), + ), + ), + ( + "duplicate output", + request( + base_tools.clone(), + json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![]), + search_output("call_search_1", vec![]) + ]), + ), + ), + ( + "unresolved call", + request(base_tools.clone(), json!([search_call("call_search_1")])), + ), + ("empty call id", blank_call), + ("empty output call id", blank_output), + ]; + + for (case, request) in cases { + assert!(ToolSearchState::build(&request).is_err(), "{case} must be rejected"); + } +} + +#[test] +fn invalid_loaded_definitions_and_normalized_collisions_are_rejected() { + let deferred = function("get_weather", "Get weather", "string", true); + let changed_schema = function("get_weather", "Get weather", "integer", true); + let base_tools = json!([search_declaration(), deferred]); + let cases = [ + ( + "schema conflict", + request( + base_tools.clone(), + json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![changed_schema]) + ]), + ), + ), + ( + "cross-kind identity conflict", + request( + base_tools.clone(), + json!([ + search_call("call_search_1"), + search_output( + "call_search_1", + vec![json!({ + "type": "mcp", + "server_label": "get_weather", + "server_description": "A different kind", + "server_url": "https://mcp.example.test/mcp", + "defer_loading": true + })] + ) + ]), + ), + ), + ( + "reserved synthetic name", + request( + base_tools, + json!([ + search_call("call_search_1"), + search_output( + "call_search_1", + vec![function("tool_search", "Conflict", "string", true)] + ) + ]), + ), + ), + ( + "normalized namespace member collision", + request( + json!([ + search_declaration(), + {"type": "function", "name": "agentic_ns__weather__forecast"} + ]), + json!([ + search_call("call_search_1"), + search_output( + "call_search_1", + vec![json!({ + "type": "namespace", + "name": "weather", + "tools": [{ + "type": "function", + "name": "forecast", + "defer_loading": true + }] + })] + ) + ]), + ), + ), + ( + "unsupported dynamically loaded custom tool", + request( + json!([search_declaration()]), + json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![json!({"type": "custom", "name": "unsupported"})]) + ]), + ), + ), + ]; + + for (case, request) in cases { + assert!(ToolSearchState::build(&request).is_err(), "{case} must be rejected"); + } +} + +#[test] +fn initial_and_dynamic_model_visible_declared_name_collisions_are_rejected() { + let initial_cases = [ + json!([ + search_declaration(), + {"type": "function", "name": "shared"}, + {"type": "custom", "name": "shared"} + ]), + json!([ + search_declaration(), + {"type": "function", "name": "web_search"}, + {"type": "web_search_preview"} + ]), + json!([ + search_declaration(), + {"type": "function", "name": "file_search"}, + {"type": "file_search", "vector_store_ids": []} + ]), + json!([ + search_declaration(), + {"type": "function", "name": "code_interpreter"}, + {"type": "code_interpreter"} + ]), + json!([ + search_declaration(), + {"type": "custom", "name": "agentic_ns__weather__forecast"}, + { + "type": "namespace", + "name": "weather", + "tools": [{"type": "function", "name": "forecast"}] + } + ]), + json!([ + search_declaration(), + {"type": "namespace", "name": "a__b", "tools": [{"type": "function", "name": "c"}]}, + {"type": "namespace", "name": "a", "tools": [{"type": "function", "name": "b__c"}]} + ]), + ]; + for tools in initial_cases { + assert!( + ToolSearchState::build(&request(tools, json!("find a tool"))).is_err(), + "initial normalized collisions must fail" + ); + } + + let dynamic_cases = [ + ( + json!([search_declaration(), {"type": "custom", "name": "shared"}]), + function("shared", "Dynamic function", "string", true), + ), + ( + json!([search_declaration(), {"type": "web_search_preview"}]), + function("web_search", "Dynamic function", "string", true), + ), + ( + json!([search_declaration(), {"type": "file_search", "vector_store_ids": []}]), + function("file_search", "Dynamic function", "string", true), + ), + ( + json!([search_declaration(), {"type": "code_interpreter"}]), + function("code_interpreter", "Dynamic function", "string", true), + ), + ( + json!([{ + "type": "namespace", + "name": "weather", + "tools": [{"type": "function", "name": "forecast"}] + }, search_declaration()]), + function("agentic_ns__weather__forecast", "Dynamic function", "string", true), + ), + ]; + for (tools, loaded) in dynamic_cases { + let input = json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![loaded]) + ]); + assert!( + ToolSearchState::build(&request(tools, input)).is_err(), + "dynamic normalized collisions must fail" + ); + } +} + +#[test] +fn canonical_mcp_equality_is_private_and_secret_sensitive() { + let mcp = json!({ + "type": "mcp", + "server_label": "weather_mcp", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/private-path", + "headers": {"X-Private-Token": "header-secret-a"}, + "authorization": "authorization-secret-a", + "defer_loading": true + }); + let matching = request( + json!([search_declaration(), mcp.clone()]), + json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![mcp.clone()]) + ]), + ); + let state = ToolSearchState::build(&matching).expect("an exact secret-bearing definition is idempotent"); + let debug = format!("{state:?}"); + for secret in ["private-path", "header-secret-a", "authorization-secret-a"] { + assert!(!debug.contains(secret), "state Debug leaked {secret}"); + } + + let mut conflicting = mcp; + conflicting["authorization"] = json!("authorization-secret-b"); + let conflict = request( + matching.tools.map_or_else( + || json!([]), + |tools| serde_json::to_value(tools).expect("tools serialize"), + ), + json!([ + search_call("call_search_2"), + search_output("call_search_2", vec![conflicting]) + ]), + ); + let error = ToolSearchState::build(&conflict).expect_err("credential changes are configuration conflicts"); + let message = error.to_string(); + for secret in [ + "private-path", + "header-secret-a", + "authorization-secret-a", + "authorization-secret-b", + ] { + assert!(!message.contains(secret), "conflict error leaked {secret}"); + } +} + +#[test] +fn loaded_mcp_model_output_projection_excludes_all_execution_configuration() { + let url_mcp = json!({ + "type": "mcp", + "server_label": "weather_url", + "server_description": "Weather URL tools", + "server_url": "https://url-user:url-password@mcp.example.test/private-path?X-Amz-Credential=query-credential&sig=query-signature", + "headers": { + "Authorization": "Bearer header-authorization", + "X-Private-Token": "header-private-token" + }, + "authorization": "top-level-authorization", + "allowed_tools": ["allowed-tool-sentinel"], + "require_approval": "approval-sentinel", + "defer_loading": true + }); + let connector_mcp = json!({ + "type": "mcp", + "server_label": "weather_connector", + "server_description": "Weather connector tools", + "connector_id": "connector-id-sentinel", + "defer_loading": true + }); + let public = request( + json!([search_declaration()]), + json!([ + search_call("call_search_url"), + search_output("call_search_url", vec![url_mcp]), + search_call("call_search_connector"), + search_output("call_search_connector", vec![connector_mcp]) + ]), + ); + let state = ToolSearchState::build(&public) + .expect("sensitive execution configuration remains valid private equality state"); + + let private_value = private_input_value(&state, &public); + let private_input = private_value.to_string(); + for forbidden in [ + "server_url", + "url-user", + "url-password", + "private-path", + "X-Amz-Credential", + "query-credential", + "query-signature", + "headers", + "header-authorization", + "header-private-token", + "authorization", + "top-level-authorization", + "connector_id", + "connector-id-sentinel", + "allowed_tools", + "allowed-tool-sentinel", + "require_approval", + "approval-sentinel", + "defer_loading", + ] { + assert!(!private_input.contains(forbidden), "private input leaked {forbidden}"); + } + assert_eq!( + [private_value[1]["output"].as_str(), private_value[3]["output"].as_str()], + [ + Some(r#"{"tools":[{"server_description":"Weather URL tools","server_label":"weather_url","type":"mcp"}]}"#), + Some( + r#"{"tools":[{"server_description":"Weather connector tools","server_label":"weather_connector","type":"mcp"}]}"# + ), + ] + ); +} + +#[test] +fn loaded_namespace_model_output_is_identity_only_while_private_tools_retain_members() { + let namespace = json!({ + "type": "namespace", + "name": "weather", + "description": "Weather tools", + "namespace_private_extra": "namespace-extra-sentinel", + "tools": [{ + "type": "function", + "name": "forecast", + "description": "Forecast member sentinel", + "parameters": { + "type": "object", + "properties": {"member-schema-sentinel": {"type": "string"}} + }, + "defer_loading": true + }] + }); + let public = request( + json!([search_declaration()]), + json!([ + search_call("call_search_namespace"), + search_output("call_search_namespace", vec![namespace]) + ]), + ); + let state = ToolSearchState::build(&public).expect("loaded namespace prepares without transport behavior"); + + let private_input = private_input_value(&state, &public); + assert_eq!( + private_input[1]["output"], + r#"{"tools":[{"description":"Weather tools","name":"weather","type":"namespace"}]}"# + ); + let private_tools = + serde_json::to_string(&private_request(&state, &public).tools).expect("private tools serialize"); + assert!( + private_tools.contains("forecast"), + "loaded member must remain available to request lowering" + ); + assert!(private_tools.contains("member-schema-sentinel")); + let private_input = private_input.to_string(); + assert!(!private_input.contains("forecast")); + assert!(!private_input.contains("member-schema-sentinel")); + assert!(!private_input.contains("namespace-extra-sentinel")); +} + +#[test] +fn namespace_partial_load_merges_members_and_is_idempotent() { + let namespace = json!({ + "type": "namespace", + "name": "weather", + "description": "Weather tools", + "tools": [ + { + "type": "function", + "name": "current", + "description": "Current conditions", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "forecast", + "description": "Weather forecast", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}} + }, + "defer_loading": true + }, + { + "type": "function", + "name": "alerts", + "description": "Weather alerts", + "parameters": {"type": "object"}, + "defer_loading": true + } + ] + }); + let loaded_subset = json!({ + "type": "namespace", + "name": "weather", + "description": "Weather tools", + "tools": [namespace["tools"][1].clone()] + }); + let public_request = request( + json!([search_declaration(), namespace.clone()]), + json!([ + search_call("call_namespace_1"), + search_output("call_namespace_1", vec![loaded_subset.clone()]), + search_call("call_namespace_2"), + search_output("call_namespace_2", vec![loaded_subset.clone()]) + ]), + ); + let state = + ToolSearchState::build(&public_request).expect("same-name partial namespace output merges exact members"); + + let public = tool_values(state.public_effective_tools()); + assert_eq!( + public[1], namespace, + "public declaration and member deferral are preserved" + ); + let loaded = tool_values(Some(state.loaded_public_tools())); + assert_eq!(loaded, json!([loaded_subset]), "exact member reload is idempotent"); + let private = private_tool_values(&state, &public_request); + assert_eq!(private[1]["tools"].as_array().map(Vec::len), Some(2)); + assert!(private[1]["tools"][0].get("defer_loading").is_none()); + assert!(private[1]["tools"][1].get("defer_loading").is_none()); + assert_eq!( + synthetic_description(&state), + "Search the client tool catalog. Available catalog entry: weather — Weather tools.", + "the remaining deferred member keeps only namespace identity in the catalog" + ); + assert!(!synthetic_description(&state).contains("alerts")); + + assert_namespace_availability_counter_transitions(&namespace, &loaded_subset); + + let mut conflicting_subset = loaded_subset; + conflicting_subset["tools"][0]["parameters"]["properties"]["city"]["type"] = json!("integer"); + assert!( + ToolSearchState::build(&request( + json!([search_declaration(), namespace]), + json!([ + search_call("call_namespace_conflict"), + search_output("call_namespace_conflict", vec![conflicting_subset]) + ]), + )) + .is_err(), + "same member identity with changed schema must conflict" + ); +} + +fn assert_namespace_availability_counter_transitions(namespace: &Value, loaded_subset: &Value) { + let alerts_subset = json!({ + "type": "namespace", + "name": "weather", + "description": "Weather tools", + "tools": [namespace["tools"][2].clone()] + }); + let fully_loaded = ToolSearchState::build(&request( + json!([search_declaration(), namespace]), + json!([ + search_call("call_forecast"), + search_output("call_forecast", vec![(*loaded_subset).clone()]), + search_call("call_forecast_repeat"), + search_output("call_forecast_repeat", vec![(*loaded_subset).clone()]), + search_call("call_alerts"), + search_output("call_alerts", vec![alerts_subset.clone()]), + search_call("call_alerts_repeat"), + search_output("call_alerts_repeat", vec![alerts_subset]) + ]), + )) + .expect("each deferred member transition is counted once"); + assert_eq!(synthetic_description(&fully_loaded), "Search the client tool catalog"); + assert_eq!( + fully_loaded.loaded_public_tools().len(), + 2, + "an exact repeat neither decrements availability twice nor duplicates the loaded subset" + ); +} + +#[test] +fn active_namespace_validation_reserves_catalog_identity_and_rejects_unsupported_shapes() { + let invalid = [ + json!({ + "type": "namespace", + "name": "tool_search", + "description": "Reserved catalog identity", + "tools": [{"type": "function", "name": "run", "defer_loading": true}] + }), + json!({"type": "namespace", "name": "empty", "tools": []}), + json!({ + "type": "namespace", + "name": "unknown_member", + "tools": [{"type": "future_member", "opaque": true}] + }), + ]; + for namespace in invalid { + assert!( + ToolSearchState::build(&request(json!([search_declaration(), namespace]), json!("find a tool"),)).is_err() + ); + } + + assert!( + ToolSearchState::build(&request( + json!([search_declaration()]), + json!([ + search_call("call_reserved_namespace"), + search_output( + "call_reserved_namespace", + vec![json!({ + "type": "namespace", + "name": "tool_search", + "tools": [{"type": "function", "name": "run"}] + })] + ) + ]), + )) + .is_err(), + "a dynamically returned namespace catalog identity is also reserved" + ); + + let ordinary = request( + json!([{ + "type": "namespace", + "name": "tool_search", + "tools": [{"type": "function", "name": "run"}] + }]), + json!("ordinary namespace"), + ); + assert!( + ToolSearchState::build(&ordinary).is_ok(), + "the namespace identity is reserved only while tool search is active" + ); +} + +#[test] +fn namespace_tool_choice_rejects_withheld_member_and_accepts_loaded_member() { + let namespace = json!({ + "type": "namespace", + "name": "weather", + "tools": [{ + "type": "function", + "name": "forecast", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }); + let mut withheld = request(json!([search_declaration(), namespace.clone()]), json!("find weather")); + withheld.tool_choice = Some( + serde_json::from_value(json!({"type": "function", "namespace": "weather", "name": "forecast"})) + .expect("namespaced choice"), + ); + let state = ToolSearchState::build(&withheld).expect("state preparation succeeds before readiness check"); + let error = state + .private_inference_request(&withheld) + .expect_err("withheld namespace member cannot be forced"); + assert!(error.to_string().contains("before its definition is loaded")); + + let mut loaded = request( + json!([search_declaration(), namespace.clone()]), + json!([ + search_call("call_namespace"), + search_output("call_namespace", vec![namespace]) + ]), + ); + loaded.tool_choice.clone_from(&withheld.tool_choice); + let state = ToolSearchState::build(&loaded).expect("exact namespace member loads"); + let private = state + .private_inference_request(&loaded) + .expect("loaded member may be selected"); + let upstream = serde_json::to_value(private.to_upstream_request(false).expect("private request lowers")) + .expect("upstream request serializes"); + assert_eq!( + upstream["tool_choice"]["name"], + model_visible_namespace_member_name("weather", "forecast") + ); + assert!(upstream["tool_choice"].get("namespace").is_none()); +} + +#[test] +fn namespace_history_rejects_exact_withheld_calls_and_lowers_loaded_calls() { + let namespace = json!({ + "type": "namespace", + "name": "weather", + "tools": [{ + "type": "function", + "name": "forecast", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }); + let flat_name = model_visible_namespace_member_name("weather", "forecast"); + for call in [ + json!({ + "type": "function_call", "id": "fc_public", "call_id": "call_public", + "namespace": "weather", "name": "forecast", "arguments": "{}", "status": "completed" + }), + json!({ + "type": "function_call", "id": "fc_flat", "call_id": "call_flat", + "name": flat_name, "arguments": "{}", "status": "completed" + }), + ] { + assert!( + ToolSearchState::build(&request( + json!([search_declaration(), namespace.clone()]), + json!([call]), + )) + .is_err(), + "an exact known-but-withheld history call must fail state preparation" + ); + } + + let loaded_call = json!({ + "type": "function_call", "id": "fc_loaded", "call_id": "call_loaded", + "namespace": "weather", "name": "forecast", "arguments": "{}", "status": "completed" + }); + let loaded_request = request( + json!([search_declaration(), namespace.clone()]), + json!([ + search_call("call_load_namespace"), + search_output("call_load_namespace", vec![namespace.clone()]), + loaded_call + ]), + ); + let state = ToolSearchState::build(&loaded_request).expect("loaded known history call remains valid"); + let private = state + .private_inference_request(&loaded_request) + .expect("loaded namespace request prepares"); + let upstream = serde_json::to_value(private.to_upstream_request(false).expect("loaded history lowers")) + .expect("upstream request serializes"); + assert_eq!(upstream["input"][2]["name"], flat_name); + assert!(upstream["input"][2].get("namespace").is_none()); + + let unknown = request( + json!([search_declaration(), namespace]), + json!([{ + "type": "function_call", "id": "fc_unknown", "call_id": "call_unknown", + "namespace": "other", "name": "ordinary", "arguments": "{}", "status": "completed" + }]), + ); + ToolSearchState::build(&unknown).expect("unknown ordinary calls retain existing behavior"); +} + +#[test] +fn top_level_function_availability_follows_ordered_search_history() { + let deferred = function("get_weather", "Get weather", "string", true); + let call = json!({ + "type": "function_call", "id": "fc_weather", "call_id": "call_weather", + "name": "get_weather", "arguments": "{}", "status": "completed" + }); + + assert!( + ToolSearchState::build(&request( + json!([search_declaration(), deferred.clone()]), + json!([call.clone()]), + )) + .is_err(), + "an initially deferred function cannot be called before it is loaded" + ); + + assert!( + ToolSearchState::build(&request( + json!([search_declaration()]), + json!([ + call.clone(), + search_call("call_dynamic"), + search_output("call_dynamic", vec![deferred.clone()]), + ]), + )) + .is_err(), + "a dynamically returned function cannot resolve an earlier call" + ); + + ToolSearchState::build(&request( + json!([search_declaration(), deferred.clone()]), + json!([ + search_call("call_load"), + search_output("call_load", vec![deferred]), + call, + ]), + )) + .expect("the same function is available after its ordered load point"); + + ToolSearchState::build(&request( + json!([search_declaration()]), + json!([{ + "type": "function_call", "id": "fc_unknown", "call_id": "call_unknown", + "name": "ordinary_client_function", "arguments": "{}", "status": "completed" + }]), + )) + .expect("unrelated unknown client functions retain existing behavior"); + + let immediate = function("get_weather", "Get weather", "string", false); + ToolSearchState::build(&request( + json!([search_declaration(), immediate.clone()]), + json!([ + { + "type": "function_call", "id": "fc_immediate", "call_id": "call_immediate", + "name": "get_weather", "arguments": "{}", "status": "completed" + }, + search_call("call_identical"), + search_output("call_identical", vec![immediate]), + ]), + )) + .expect("an immediate function stays available before an identical search result"); +} + +#[test] +fn top_level_function_tool_choices_require_the_definition_to_be_loaded() { + let deferred = function("get_weather", "Get weather", "string", true); + for choice in [ + json!({"type": "function", "name": "get_weather"}), + json!({ + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_weather"}] + }), + ] { + let mut withheld = request(json!([search_declaration(), deferred.clone()]), json!("find weather")); + withheld.tool_choice = Some(serde_json::from_value(choice).expect("function tool choice")); + let state = ToolSearchState::build(&withheld).expect("state preparation succeeds before choice validation"); + state + .private_inference_request(&withheld) + .expect_err("a withheld function cannot be selected"); + } + + let mut loaded = request( + json!([search_declaration(), deferred.clone()]), + json!([ + search_call("call_load_choice"), + search_output("call_load_choice", vec![deferred]), + ]), + ); + loaded.tool_choice = + Some(serde_json::from_value(json!({"type": "function", "name": "get_weather"})).expect("function tool choice")); + let state = ToolSearchState::build(&loaded).expect("loaded state"); + state + .private_inference_request(&loaded) + .expect("a loaded function may be selected"); +} + +#[test] +fn dynamically_returned_namespace_members_start_loaded_without_catalog_debt() { + let dynamic_namespace = json!({ + "type": "namespace", + "name": "dynamic_weather", + "description": "Dynamically returned weather tools", + "tools": [{ + "type": "function", + "name": "forecast", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }); + let public = request( + json!([search_declaration()]), + json!([ + search_call("call_dynamic_namespace"), + search_output("call_dynamic_namespace", vec![dynamic_namespace]) + ]), + ); + let state = ToolSearchState::build(&public).expect("dynamically returned namespace members are already loaded"); + + assert_eq!(synthetic_description(&state), "Search the client tool catalog"); + let private = private_tool_values(&state, &public); + assert_eq!(private[1]["tools"].as_array().map(Vec::len), Some(1)); + assert!(private[1]["tools"][0].get("defer_loading").is_none()); +} + +#[test] +fn dynamic_namespace_history_rejects_forward_references_but_accepts_valid_order_and_unknowns() { + let dynamic_namespace = json!({ + "type": "namespace", + "name": "dynamic_weather", + "tools": [{ + "type": "function", "name": "forecast", "parameters": {"type": "object"}, + "defer_loading": true + }] + }); + let flat_name = model_visible_namespace_member_name("dynamic_weather", "forecast"); + let public_call = json!({ + "type": "function_call", "id": "fc_public", "call_id": "call_public", + "namespace": "dynamic_weather", "name": "forecast", "arguments": "{}", "status": "completed" + }); + let flat_call = json!({ + "type": "function_call", "id": "fc_flat", "call_id": "call_flat", + "name": flat_name, "arguments": "{}", "status": "completed" + }); + for call in [public_call.clone(), flat_call.clone()] { + assert!( + ToolSearchState::build(&request( + json!([search_declaration()]), + json!([ + call, + search_call("call_dynamic"), + search_output("call_dynamic", vec![dynamic_namespace.clone()]) + ]), + )) + .is_err(), + "a call cannot forward-reference a namespace member loaded later in ordered history" + ); + } + + for call in [public_call, flat_call] { + let valid = request( + json!([search_declaration()]), + json!([ + search_call("call_dynamic"), + search_output("call_dynamic", vec![dynamic_namespace.clone()]), + call + ]), + ); + let state = ToolSearchState::build(&valid).expect("output-before-call order is valid"); + let private = state + .private_inference_request(&valid) + .expect("valid ordered request prepares"); + let upstream = serde_json::to_value(private.to_upstream_request(false).expect("valid history lowers")) + .expect("upstream request serializes"); + assert_eq!(upstream["input"][2]["name"], flat_name); + assert!(upstream["input"][2].get("namespace").is_none()); + } + + for unknown in [ + json!({ + "type": "function_call", "id": "fc_unknown_public", "call_id": "call_unknown_public", + "namespace": "never_loaded", "name": "ordinary", "arguments": "{}", "status": "completed" + }), + json!({ + "type": "function_call", "id": "fc_unknown_flat", "call_id": "call_unknown_flat", + "name": "agentic_ns__never_loaded__ordinary", "arguments": "{}", "status": "completed" + }), + ] { + ToolSearchState::build(&request(json!([search_declaration()]), json!([unknown]))) + .expect("a call that remains unknown retains existing behavior"); + } +} + +#[test] +fn deferred_declarations_do_not_implicitly_synthesize_search() { + let request = request( + json!([function("get_weather", "Get weather", "string", true)]), + json!("find weather"), + ); + + assert!( + ToolSearchState::build(&request).is_err(), + "defer_loading activates routing safety but requires a declaration or replayed search state" + ); +} + +#[test] +fn declaration_free_manual_replay_builds_loaded_views_without_a_synthetic_declaration() { + let dynamic = function("get_weather", "Get weather", "string", true); + let public_request = request( + json!([]), + json!([ + search_call("call_search_1"), + search_output("call_search_1", vec![dynamic.clone()]) + ]), + ); + let state = + ToolSearchState::build(&public_request).expect("manual public replay is valid without redeclaring tool_search"); + + assert!(state.is_active()); + assert!(state.synthetic_function().is_none()); + assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([dynamic])); + let public = tool_values(state.public_effective_tools()); + let private = private_tool_values(&state, &public_request); + assert_eq!(public[0]["defer_loading"], true); + assert!(private[0].get("defer_loading").is_none()); +} + +#[test] +fn private_inference_request_consumes_prepared_views_without_mutating_public_state() { + let deferred = function("get_weather", "Get weather", "string", true); + let public = request( + json!([search_declaration(), deferred.clone()]), + json!([ + {"type": "message", "role": "user", "content": "find weather"}, + search_call("call_search_1"), + search_output("call_search_1", vec![deferred.clone()]) + ]), + ); + let public_before = serde_json::to_value(&public).expect("public request serializes"); + let state = ToolSearchState::build(&public).expect("valid function-only state"); + assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([deferred])); + assert_eq!(synthetic_description(&state), "Search the client tool catalog"); + + let private = state + .private_inference_request(&public) + .expect("private inference request consumes the prepared private view"); + + assert_eq!( + serde_json::to_value(&public).expect("public request still serializes"), + public_before, + "private lowering must not mutate the public request" + ); + let private_value = serde_json::to_value(private).expect("private request serializes"); + assert_eq!(private_value["input"][1]["type"], "function_call"); + assert_eq!(private_value["input"][1]["name"], "tool_search"); + assert_eq!(private_value["input"][1]["call_id"], "call_search_1"); + assert_eq!(private_value["input"][2]["type"], "function_call_output"); + assert_eq!(private_value["input"][2]["call_id"], "call_search_1"); + assert_eq!(private_value["tools"].as_array().map(Vec::len), Some(2)); + assert_eq!(private_value["tools"][0]["name"], "tool_search"); + assert_eq!(private_value["tools"][1]["name"], "get_weather"); + assert!(private_value["tools"][1].get("defer_loading").is_none()); +} + +#[test] +fn safe_catalog_is_minimal_and_never_exposes_deferred_configuration() { + let request = request( + json!([ + search_declaration(), + { + "type": "function", + "name": "hidden_function", + "description": "Safe function description", + "parameters": { + "type": "object", + "properties": {"secret_parameter": {"type": "string"}} + }, + "defer_loading": true + }, + { + "type": "namespace", + "name": "hidden_namespace", + "description": "Safe namespace description", + "tools": [{ + "type": "function", + "name": "secret_member", + "description": "secret member description", + "parameters": {"type": "object", "properties": {"secret": {"type": "string"}}}, + "defer_loading": true + }] + }, + { + "type": "mcp", + "server_label": "hidden_mcp", + "server_description": "Safe MCP description", + "server_url": "https://mcp.example.test/secret-path", + "headers": {"Authorization": "Bearer catalog-secret"}, + "authorization": "catalog-authorization-secret", + "allowed_tools": ["secret_discovered_tool"], + "require_approval": "never", + "defer_loading": true + }, + { + "type": "mcp", + "server_label": "hidden_connector", + "server_description": "Safe connector description", + "connector_id": "connector-secret", + "defer_loading": true + } + ]), + json!("find a tool"), + ); + + let public_before = serde_json::to_value(&request).expect("public request serializes"); + let state = ToolSearchState::build(&request).expect("catalog construction is pure and needs no MCP connection"); + let private = private_request(&state, &request); + assert_eq!( + serde_json::to_value(&request).expect("public request still serializes"), + public_before, + "private materialization must preserve public deferred configuration" + ); + assert!( + private + .tools + .as_deref() + .expect("private tools") + .iter() + .all(|tool| !matches!(tool, agentic_core::ResponsesTool::Mcp(_))), + "deferred MCP entries must remain outside the private inference request" + ); + assert_eq!( + synthetic_description(&state), + "Search the client tool catalog. Available catalog entries: hidden_function — Safe function description; \ +hidden_namespace — Safe namespace description; hidden_mcp — Safe MCP description; hidden_connector — Safe connector \ +description." + ); + + let model_visible = serde_json::to_string(&state.synthetic_function()).expect("synthetic declaration serializes"); + for secret in [ + "secret_parameter", + "secret_member", + "secret member description", + "secret-path", + "catalog-secret", + "catalog-authorization-secret", + "secret_discovered_tool", + "connector-secret", + "require_approval", + ] { + assert!(!model_visible.contains(secret), "catalog leaked {secret}"); + } +} + +#[test] +fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { + let deferred = json!({ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }); + let request = request( + json!([search_declaration(), deferred]), + json!([{ + "type": "compaction", + "encrypted_content": "The weather tool was loaded earlier." + }]), + ); + let restored: Vec = serde_json::from_value(json!([{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }])) + .expect("valid restored loaded definitions"); + + let state = ToolSearchState::build_with_loaded_tools(&request, &restored, false) + .expect("typed metadata restores compaction-lost loaded state"); + + assert_eq!(state.loaded_public_tools().len(), 1); + assert_eq!(synthetic_description(&state), "Search the client tool catalog"); + let private_request = private_request(&state, &request); + let private = private_request.tools.as_deref().expect("private tools"); + let loaded = private + .iter() + .find_map(|tool| match tool { + agentic_core::types::tools::ResponsesTool::Function(function) + if function.name.as_str() == "get_weather" => + { + Some(function) + } + _ => None, + }) + .expect("loaded function is effective upstream"); + assert_eq!(loaded.defer_loading, None); +} + +#[test] +fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { + let request = request( + json!([search_declaration()]), + json!([ + { + "type": "tool_search_call", + "id": "tsc_obsolete", + "call_id": "call_obsolete", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_obsolete", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }] + }, + { + "type": "compaction", + "encrypted_content": "The old search pair is superseded." + }, + { + "type": "message", + "role": "user", + "content": "Continue without weather" + } + ]), + ); + let restored: Vec = serde_json::from_value(json!([{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + }])) + .expect("stored loaded definition"); + + let state = ToolSearchState::build_with_loaded_tools(&request, &restored, true) + .expect("explicit tools define the post-compaction catalog"); + assert!(state.loaded_public_tools().is_empty()); + assert!(state + .public_effective_tools() + .unwrap() + .iter() + .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); + assert!(private_request(&state, &request) + .tools + .as_deref() + .expect("private tools") + .iter() + .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); +} + +#[test] +fn replayed_mcp_loaded_marker_accepts_reprovided_credentials_but_rejects_public_config_change() { + let base_request = request( + json!([ + search_declaration(), + { + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "headers": {"X-API-Key": "fresh-secret"}, + "authorization": "fresh-authorization", + "require_approval": "never", + "defer_loading": true + } + ]), + json!([{ + "type": "compaction", + "encrypted_content": "Weather MCP was loaded earlier." + }]), + ); + let restored: Vec = serde_json::from_value(json!([{ + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "require_approval": "never", + "defer_loading": true + }])) + .expect("sanitized persisted MCP marker"); + + let state = ToolSearchState::build_with_loaded_tools(&base_request, &restored, true) + .expect("reprovided credentials do not conflict with sanitized persisted state"); + let private_request = private_request(&state, &base_request); + let loaded = private_request + .tools + .as_deref() + .expect("private tools") + .iter() + .find_map(|tool| match tool { + agentic_core::types::tools::ResponsesTool::Mcp(mcp) => Some(mcp), + _ => None, + }) + .expect("loaded MCP remains effective"); + assert_eq!( + loaded + .headers + .as_ref() + .and_then(|headers| headers.get("X-API-Key")) + .map(String::as_str), + Some("fresh-secret") + ); + assert_eq!(loaded.authorization.as_deref(), Some("fresh-authorization")); + assert_eq!(loaded.defer_loading, None); + + let persisted_history_request = request( + json!([ + search_declaration(), + { + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "headers": {"X-API-Key": "fresh-secret"}, + "authorization": "fresh-authorization", + "require_approval": "never", + "defer_loading": true + } + ]), + json!([ + { + "type": "tool_search_call", + "id": "tsc_stored_mcp", + "call_id": "call_stored_mcp", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_stored_mcp", + "tools": [{ + "type": "mcp", + "server_label": "weather", + "server_description": "Weather tools", + "server_url": "https://mcp.example.test/mcp", + "require_approval": "never", + "defer_loading": true + }] + } + ]), + ); + ToolSearchState::build_with_loaded_tools(&persisted_history_request, &restored, true) + .expect("trusted restored MCP state reconciles its sanitized persisted output"); + assert!( + ToolSearchState::build(&persisted_history_request).is_err(), + "fresh untrusted sanitized output must not weaken credential-sensitive equality" + ); + + let mut changed = restored; + let agentic_core::types::tools::ResponsesTool::Mcp(changed_mcp) = &mut changed[0] else { + panic!("MCP marker") + }; + changed_mcp.server_url = Some("https://mcp.example.test/changed".to_owned()); + assert!(ToolSearchState::build_with_loaded_tools(&base_request, &changed, true).is_err()); +} + +#[test] +fn replayed_loaded_marker_rejects_explicit_cross_kind_identity_collision() { + let request = request( + json!([ + search_declaration(), + { + "type": "namespace", + "name": "shared_identity", + "description": "Replacement namespace", + "tools": [{ + "type": "function", + "name": "member", + "parameters": {"type": "object"} + }] + } + ]), + json!([{ + "type": "compaction", + "encrypted_content": "A function with this name was loaded earlier." + }]), + ); + let restored: Vec = serde_json::from_value(json!([{ + "type": "function", + "name": "shared_identity", + "description": "Original function", + "parameters": {"type": "object"}, + "defer_loading": true + }])) + .expect("stored function marker"); + + assert!(ToolSearchState::build_with_loaded_tools(&request, &restored, true).is_err()); +} diff --git a/crates/agentic-server-core/tests/tool_search_test.rs b/crates/agentic-server-core/tests/tool_search_test.rs new file mode 100644 index 00000000..6b21715d --- /dev/null +++ b/crates/agentic-server-core/tests/tool_search_test.rs @@ -0,0 +1,1822 @@ +mod support; + +use std::collections::HashSet; +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::fs; +use std::future::Future; +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use agentic_core::executor::{ConversationHandler, ExecuteRequest, ExecutionContext, ResponseHandler}; +use agentic_core::storage::{ConversationStore, ResponseStore, create_pool_with_schema}; +use agentic_core::tool::{ + GatewayExecutor, ToolError, ToolHandler, ToolOutput, ToolType, model_visible_namespace_member_name, +}; +use agentic_core::types::io::{FunctionTool, OutputItem}; +use agentic_core::types::request_response::{RequestPayload, ResponsePayload}; +use axum::Router; +use axum::body::Bytes; +use axum::response::IntoResponse; +use axum::routing::post; +use either::Either; +use futures::StreamExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +#[derive(Default)] +struct McpRequestCounts { + initialize: AtomicUsize, + list_tools: AtomicUsize, + call_tool: AtomicUsize, +} + +#[derive(Debug)] +struct CountingWebSearch { + calls: Arc, +} + +impl ToolHandler for CountingWebSearch { + fn tool_type(&self) -> ToolType { + ToolType::WebSearch + } + + fn validate(&self, _param: &Value) -> Result<(), ToolError> { + Ok(()) + } + + fn normalize(&self, _param: &Value) -> Vec { + Vec::new() + } +} + +impl GatewayExecutor for CountingWebSearch { + fn execute( + &self, + call_id: &str, + _tool_name: &str, + _arguments: &str, + _config: &Value, + ) -> Pin> + Send + '_>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(std::future::ready(Ok(ToolOutput { + call_id: call_id.to_owned(), + output: "must not execute".to_owned(), + }))) + } +} + +async fn spawn_sequenced_llm(responses: Vec) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let responses = Arc::new(Mutex::new(VecDeque::from(responses))); + let requests = Arc::new(Mutex::new(Vec::new())); + let route_responses = Arc::clone(&responses); + let route_requests = Arc::clone(&requests); + let app = Router::new().route( + "/v1/responses", + post(move |body: Bytes| { + let route_responses = Arc::clone(&route_responses); + let route_requests = Arc::clone(&route_requests); + async move { + route_requests + .lock() + .await + .push(serde_json::from_slice(&body).expect("captured request JSON")); + let response = route_responses + .lock() + .await + .pop_front() + .expect("one prepared response per request"); + axum::response::Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(axum::body::Body::from(response.to_string())) + .expect("mock response") + .into_response() + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock LLM"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.expect("mock server") }); + (format!("http://{address}"), requests, handle) +} + +async fn spawn_sequenced_streaming_llm( + responses: Vec, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let responses = Arc::new(Mutex::new(VecDeque::from(responses))); + let requests = Arc::new(Mutex::new(Vec::new())); + let route_responses = Arc::clone(&responses); + let route_requests = Arc::clone(&requests); + let app = Router::new().route( + "/v1/responses", + post(move |body: Bytes| { + let route_responses = Arc::clone(&route_responses); + let route_requests = Arc::clone(&route_requests); + async move { + route_requests + .lock() + .await + .push(serde_json::from_slice(&body).expect("captured request JSON")); + let response = route_responses + .lock() + .await + .pop_front() + .expect("one prepared response per request"); + axum::response::Response::builder() + .status(200) + .header("Content-Type", "text/event-stream") + .body(axum::body::Body::from(response)) + .expect("mock response") + .into_response() + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock LLM"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.expect("mock server") }); + (format!("http://{address}"), requests, handle) +} + +fn streaming_search_response(arguments: &str) -> String { + let events = [ + json!({ + "type": "response.created", "sequence_number": 0, + "response": { + "id": "upstream_search", "status": "in_progress", + "tools": [{ + "type": "function", "name": "tool_search", + "description": "private normalized catalog", + "parameters": {"type": "object"} + }] + } + }), + json!({ + "type": "response.in_progress", "sequence_number": 1, + "response": { + "id": "upstream_search", "status": "in_progress", + "tools": [{ + "type": "function", "name": "tool_search", + "description": "private normalized catalog", + "parameters": {"type": "object"} + }] + } + }), + json!({ + "type": "response.output_item.added", "sequence_number": 2, "output_index": 0, + "item": {"id": "fc_search_1", "type": "function_call", "call_id": "call_search_1", + "name": "tool_search", "arguments": "", "status": "in_progress"} + }), + json!({ + "type": "response.function_call_arguments.delta", "sequence_number": 3, "output_index": 0, + "item_id": "fc_search_1", "delta": arguments + }), + json!({ + "type": "response.function_call_arguments.done", "sequence_number": 4, "output_index": 0, + "item_id": "fc_search_1", "name": "tool_search", "arguments": arguments + }), + json!({ + "type": "response.output_item.done", "sequence_number": 5, "output_index": 0, + "item": {"id": "fc_search_1", "type": "function_call", "call_id": "call_search_1", + "name": "tool_search", "arguments": arguments, "status": "completed"} + }), + json!({ + "type": "response.completed", "sequence_number": 6, + "response": { + "id": "upstream_search", "status": "completed", "usage": null, + "output": [{ + "type": "function_call", "id": "fc_provider_terminal_regenerated", + "call_id": "call_search_1", "name": "tool_search", + "arguments": arguments, "status": "completed" + }] + } + }), + ]; + streaming_response(events) +} + +fn streaming_response(events: impl IntoIterator) -> String { + let mut response = String::new(); + for event in events { + writeln!(&mut response, "data: {event}\n").expect("writing to String cannot fail"); + } + response.push_str("data: [DONE]\n\n"); + response +} + +fn streaming_partial_search_failure_response() -> String { + let events = [ + json!({ + "type": "response.created", + "response": {"id": "upstream_failed", "status": "in_progress"} + }), + json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_partial", "type": "function_call", "call_id": "call_partial", + "arguments": "", "status": "in_progress"} + }), + json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": "fc_partial", "delta": "{\"query\":\"weather" + }), + json!({ + "type": "response.failed", + "response": { + "id": "upstream_failed", "status": "failed", "usage": null, + "error": {"code": "provider_failure", "message": "provider stopped"}, + "incomplete_details": {"reason": "upstream_error"} + } + }), + ]; + streaming_response(events) +} + +fn streaming_pending_call_overflow_response() -> String { + let mut events = vec![json!({ + "type": "response.created", + "response": {"id": "upstream_pending_overflow", "status": "in_progress"} + })]; + events.extend((0..=128).map(|output_index| { + json!({ + "type": "response.output_item.added", "output_index": output_index, + "item": {"id": format!("fc_pending_{output_index}"), "type": "function_call", + "call_id": format!("call_pending_{output_index}"), "arguments": "", "status": "in_progress"} + }) + })); + streaming_response(events) +} + +fn streaming_gateway_call_then_malformed_search_response() -> String { + let events = [ + json!({"type":"response.created","response":{"id":"up_mixed","status":"in_progress"}}), + json!({ + "type":"response.output_item.added","output_index":0, + "item":{"id":"fc_web","type":"function_call","status":"in_progress", + "name":"web_search","call_id":"call_web","arguments":""} + }), + json!({ + "type":"response.output_item.done","output_index":0, + "item":{"id":"fc_web","type":"function_call","status":"completed", + "name":"web_search","call_id":"call_web","arguments":"{\"query\":\"weather\"}"} + }), + json!({ + "type":"response.output_item.added","output_index":1, + "item":{"id":"fc_search_bad","type":"function_call","status":"in_progress", + "name":"tool_search","call_id":"call_search_bad","arguments":""} + }), + json!({ + "type":"response.function_call_arguments.delta","output_index":1, + "item_id":"fc_search_bad","delta":"[]" + }), + json!({ + "type":"response.function_call_arguments.done","output_index":1, + "item_id":"fc_search_bad","name":"tool_search","arguments":"[]" + }), + ]; + streaming_response(events) +} + +fn streaming_named_function_response(name: &str) -> String { + streaming_response([ + json!({ + "type": "response.created", "sequence_number": 0, + "response": {"id": "upstream_withheld", "status": "in_progress"} + }), + json!({ + "type": "response.output_item.added", "sequence_number": 1, "output_index": 0, + "item": {"id": "fc_withheld", "type": "function_call", "call_id": "call_withheld", + "name": name, "arguments": "", "status": "in_progress"} + }), + ]) +} + +fn streaming_late_named_function_response(name: &str) -> String { + streaming_response([ + json!({ + "type": "response.created", "sequence_number": 0, + "response": {"id": "upstream_withheld", "status": "in_progress"} + }), + json!({ + "type": "response.output_item.added", "sequence_number": 1, "output_index": 0, + "item": {"id": "fc_withheld", "type": "function_call", "call_id": "call_withheld", + "arguments": "", "status": "in_progress"} + }), + json!({ + "type": "response.function_call_arguments.done", "sequence_number": 2, "output_index": 0, + "item_id": "fc_withheld", "name": name, "arguments": "{}" + }), + ]) +} + +fn streaming_terminal_function_response(name: &str) -> String { + streaming_response([ + json!({ + "type": "response.created", "sequence_number": 0, + "response": {"id": "upstream_withheld", "status": "in_progress"} + }), + json!({ + "type": "response.completed", "sequence_number": 1, + "response": { + "id": "upstream_withheld", "status": "completed", "usage": null, + "output": [{ + "type": "function_call", "id": "fc_withheld", "call_id": "call_withheld", + "name": name, "arguments": "{}", "status": "completed" + }] + } + }), + ]) +} + +async fn run_streaming(request: RequestPayload, context: Arc) -> Vec { + let Either::Right(mut stream) = ExecuteRequest::new(request, context) + .run() + .await + .expect("streaming execution") + else { + panic!("streaming request must return a stream") + }; + let mut events = Vec::new(); + while let Some(chunk) = stream.next().await { + for line in chunk.lines() { + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if data != "[DONE]" { + events.push(serde_json::from_str(data).expect("stream event JSON")); + } + } + } + events +} + +async fn spawn_counting_mcp() -> (String, Arc, tokio::task::JoinHandle<()>) { + let counts = Arc::new(McpRequestCounts::default()); + let route_counts = Arc::clone(&counts); + let app = Router::new().route( + "/mcp", + post(move |body: Bytes| { + let route_counts = Arc::clone(&route_counts); + async move { + let request: Value = serde_json::from_slice(&body).expect("MCP request JSON"); + let method = request["method"].as_str().unwrap_or_default(); + let id = request.get("id").cloned().unwrap_or(Value::Null); + let response = match method { + "initialize" => { + route_counts.initialize.fetch_add(1, Ordering::SeqCst); + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "counting-mcp", "version": "1.0.0"} + } + })) + } + "notifications/initialized" => None, + "tools/list" => { + route_counts.list_tools.fetch_add(1, Ordering::SeqCst); + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": [{ + "name": "forecast", + "description": "Return a forecast", + "inputSchema": { + "type": "object", + "properties": {"city": {"type": "string"}} + } + }] + } + })) + } + "tools/call" => { + route_counts.call_tool.fetch_add(1, Ordering::SeqCst); + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{"type": "text", "text": "sunny"}], + "isError": false + } + })) + } + other => panic!("unexpected MCP method {other}"), + }; + + match response { + Some(response) => axum::response::Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(axum::body::Body::from(response.to_string())) + .expect("MCP response") + .into_response(), + None => axum::response::Response::builder() + .status(202) + .body(axum::body::Body::empty()) + .expect("MCP notification response") + .into_response(), + } + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind MCP server"); + let address = listener.local_addr().expect("MCP address"); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.expect("MCP server") }); + (format!("http://{address}/mcp"), counts, handle) +} + +fn request(input: &Value, tools: &Value) -> RequestPayload { + serde_json::from_value(json!({ + "model": "test", + "input": input, + "tools": tools, + "store": false, + "stream": false, + "parallel_tool_calls": false + })) + .expect("public request") +} + +async fn run(request: RequestPayload, context: Arc) -> ResponsePayload { + match Box::pin(ExecuteRequest::new(request, context).run()) + .await + .expect("blocking execution") + { + Either::Left(response) => response, + Either::Right(_) => panic!("blocking helper received a streaming response"), + } +} + +fn assert_public_search_call(response: &ResponsePayload) -> Value { + let OutputItem::ToolSearchCall(search_call) = &response.output[0] else { + panic!("normalized search call must be public") + }; + assert_eq!(search_call.id, "tsc_search_1"); + assert_eq!(search_call.call_id, "call_search_1"); + assert_eq!( + search_call.arguments, + serde_json::from_value(json!({"query": "weather"})).unwrap() + ); + let public_search_call = serde_json::to_value(&response.output[0]).expect("public search call serializes"); + assert_eq!(public_search_call["execution"], "client"); + assert_eq!(public_search_call["status"], "completed"); + public_search_call +} + +fn assert_private_request_sequence(requests: &[Value]) { + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(requests[0]["tools"][0]["name"], "tool_search"); + for request in &requests[1..] { + assert!(request.get("previous_response_id").is_none()); + assert_eq!(request["input"][1]["type"], "function_call"); + assert_eq!(request["input"][1]["name"], "tool_search"); + assert_eq!(request["input"][1]["call_id"], "call_search_1"); + assert_eq!(request["input"][2]["type"], "function_call_output"); + assert_eq!(request["input"][2]["call_id"], "call_search_1"); + assert_eq!(request["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(request["tools"][0]["name"], "get_weather"); + assert!(request["tools"][0].get("defer_loading").is_none()); + } +} + +fn assert_mcp_private_request_sequence(requests: &[Value], mcp_url: &str) { + assert_eq!(requests.len(), 3); + let initial_tool_names = requests[0]["tools"] + .as_array() + .expect("initial private tools") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!(initial_tool_names, ["tool_search"]); + assert!(!requests[0].to_string().contains(mcp_url)); + for request in &requests[1..] { + let tools = request["tools"].as_array().expect("private tools"); + let tool_names = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!(tool_names, ["tool_search", "mcp__weather__forecast"]); + assert!(tools.iter().all(|tool| tool.get("defer_loading").is_none())); + } +} + +fn search_declaration() -> Value { + json!({ + "type": "tool_search", + "execution": "client", + "description": "Search the client tool catalog", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }) +} + +fn deferred_weather() -> Value { + json!({ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + }, + "strict": true, + "defer_loading": true + }) +} + +fn deferred_weather_mcp(server_url: &str) -> Value { + json!({ + "type": "mcp", + "server_label": "weather", + "server_description": "Weather server", + "server_url": server_url, + "allowed_tools": ["forecast"], + "require_approval": "never", + "defer_loading": true + }) +} + +fn completed_search_replay() -> Value { + json!([ + { + "type": "tool_search_call", + "id": "tsc_prior", + "call_id": "call_prior", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }, + { + "type": "tool_search_output", + "call_id": "call_prior", + "execution": "client", + "status": "completed", + "tools": [deferred_weather()] + } + ]) +} + +fn weather_namespace() -> Value { + json!({ + "type": "namespace", + "name": "weather_namespace_with_a_name_long_enough_to_require_bounded_flattening", + "description": "Weather tools", + "tools": [ + { + "type": "function", + "name": "current", + "parameters": {"type": "object"} + }, + { + "type": "function", + "name": "forecast_member_with_a_name_long_enough_to_require_bounded_flattening", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}} + }, + "defer_loading": true + } + ] + }) +} + +fn loaded_weather_namespace_subset() -> Value { + let namespace = weather_namespace(); + json!({ + "type": "namespace", + "name": namespace["name"], + "description": namespace["description"], + "tools": [namespace["tools"][1].clone()] + }) +} + +#[tokio::test] +async fn function_only_streaming_search_has_public_lifecycle_terminal_and_private_lowering() { + let (llm_url, requests, _server) = + spawn_sequenced_streaming_llm(vec![streaming_search_response(r#"{"query":"weather"}"#)]).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut payload = request( + &json!("find weather"), + &json!([search_declaration(), deferred_weather()]), + ); + payload.stream = true; + + let events = run_streaming(payload, context).await; + + let response_envelopes = events + .iter() + .filter_map(|event| event.get("response")) + .filter(|response| response.get("tools").is_some()) + .collect::>(); + assert!(!response_envelopes.is_empty()); + for response in response_envelopes { + assert_eq!( + response["tools"], + json!([search_declaration(), deferred_weather()]), + "stream response envelopes must restore public tool declarations" + ); + assert!(!response["tools"].to_string().contains("\"name\":\"tool_search\"")); + } + + assert_eq!( + events + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(events.len()).unwrap()).map(Some).collect::>() + ); + assert!(events.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done" | "error") + ) + })); + let lifecycle = events + .iter() + .filter(|event| { + matches!( + event["type"].as_str(), + Some("response.output_item.added" | "response.output_item.done") + ) + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["item"]["type"], "tool_search_call"); + assert_eq!(lifecycle[0]["item"]["status"], "in_progress"); + assert_eq!(lifecycle[0]["item"]["arguments"], json!({})); + assert_eq!(lifecycle[1]["item"]["type"], "tool_search_call"); + assert_eq!(lifecycle[1]["item"]["status"], "completed"); + assert_eq!(lifecycle[1]["item"]["arguments"], json!({"query": "weather"})); + assert_eq!(lifecycle[0]["item"]["id"], lifecycle[1]["item"]["id"]); + assert_eq!(lifecycle[0]["item"]["call_id"], lifecycle[1]["item"]["call_id"]); + + let terminal = events + .iter() + .find(|event| event["type"] == "response.completed") + .expect("terminal response"); + let terminal_call = &terminal["response"]["output"][0]; + assert_eq!(terminal_call, &lifecycle[1]["item"]); + + let captured = requests.lock().await; + assert_eq!(captured.len(), 1); + assert_eq!(captured[0]["stream"], true); + assert_eq!(captured[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(captured[0]["tools"][0]["name"], "tool_search"); + assert!(captured[0].to_string().contains("get_weather")); + assert!(!captured[0].to_string().contains("\"city\"")); +} + +#[tokio::test] +async fn malformed_streaming_search_finishes_with_response_failed_not_completed() { + let (llm_url, _requests, _server) = spawn_sequenced_streaming_llm(vec![streaming_search_response("[]")]).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut payload = request( + &json!("find weather"), + &json!([search_declaration(), deferred_weather()]), + ); + payload.stream = true; + + let events = run_streaming(payload, context).await; + + assert!(events.iter().all(|event| event["type"] != "response.completed")); + assert!(events.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done" | "error") + ) + })); + let failed = events.last().expect("response.failed"); + assert_eq!(failed["type"], "response.failed"); + assert_eq!(failed["response"]["status"], "failed"); + assert_eq!(failed["response"]["error"]["type"], "tool_error"); + assert_eq!(failed["response"]["error"]["code"], "tool_error"); + assert_eq!( + events + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(events.len()).unwrap()).map(Some).collect::>() + ); +} + +#[tokio::test] +async fn withheld_function_streams_fail_without_public_call_or_persistence() { + let namespace = weather_namespace(); + let flat_name = model_visible_namespace_member_name( + namespace["name"].as_str().expect("namespace name"), + namespace["tools"][1]["name"].as_str().expect("member name"), + ); + let cases = [ + ( + "get_weather".to_owned(), + json!([search_declaration(), deferred_weather()]), + ), + (flat_name, json!([search_declaration(), namespace])), + ]; + + for (name, tools) in cases { + for response in [ + streaming_named_function_response(&name), + streaming_late_named_function_response(&name), + streaming_terminal_function_response(&name), + ] { + let (llm_url, requests, _server) = spawn_sequenced_streaming_llm(vec![response]).await; + let pool = create_pool_with_schema(Some("sqlite://?mode=memory")) + .await + .expect("storage schema"); + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::new(Arc::clone(&pool))), + ResponseHandler::new(ResponseStore::new(Arc::clone(&pool))), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut payload = request(&json!("find a tool"), &tools); + payload.stream = true; + payload.store = true; + + let events = run_streaming(payload, context).await; + + let failed = events.last().expect("response.failed"); + assert_eq!(failed["type"], "response.failed", "{name}"); + assert_eq!(failed["response"]["error"]["code"], "tool_error", "{name}"); + assert!(events.iter().all(|event| event["type"] != "response.completed")); + assert!(events.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) && event + .get("item") + .and_then(|item| item.get("name")) + .and_then(Value::as_str) + != Some(name.as_str()) + })); + let response_id = failed["response"]["id"].as_str().expect("failed response ID"); + let error = ResponseStore::new(pool) + .get(response_id) + .await + .expect_err("invalid streamed response must not persist"); + assert!(error.is_not_found()); + assert_eq!(requests.lock().await.len(), 1, "only inference may run"); + } + } +} + +#[tokio::test] +async fn upstream_failure_after_partial_search_preserves_provider_error_without_normalized_output() { + let (llm_url, _requests, _server) = + spawn_sequenced_streaming_llm(vec![streaming_partial_search_failure_response()]).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut immediate_weather = deferred_weather(); + immediate_weather + .as_object_mut() + .expect("function tool") + .remove("defer_loading"); + let mut payload = request( + &json!("find weather"), + &json!([search_declaration(), immediate_weather]), + ); + payload.stream = true; + + let events = run_streaming(payload, context).await; + + let failed = events.last().expect("response.failed"); + assert_eq!(failed["type"], "response.failed"); + assert_eq!(failed["response"]["error"]["code"], "provider_failure"); + assert_eq!(failed["response"]["error"]["message"], "provider stopped"); + assert_eq!(failed["response"]["incomplete_details"]["reason"], "upstream_error"); + assert_eq!(failed["response"]["output"], json!([])); + assert!(events.iter().all(|event| event["type"] != "response.completed")); + assert!(events.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) + })); +} + +#[tokio::test] +async fn search_active_pending_call_overflow_uses_standard_response_failed() { + let (llm_url, _requests, _server) = + spawn_sequenced_streaming_llm(vec![streaming_pending_call_overflow_response()]).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut payload = request( + &json!("find weather"), + &json!([search_declaration(), deferred_weather()]), + ); + payload.stream = true; + + let events = run_streaming(payload, context).await; + + let failed = events.last().expect("response.failed"); + assert_eq!(failed["type"], "response.failed"); + assert_eq!(failed["response"]["error"]["code"], "tool_error"); + assert!(events.iter().all(|event| event["type"] != "error")); + assert!(events.iter().all(|event| event["type"] != "response.completed")); + assert_eq!( + events + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(events.len()).unwrap()).map(Some).collect::>() + ); +} + +#[tokio::test] +async fn malformed_streaming_search_is_not_dispatched_or_persisted_after_start() { + let (llm_url, _requests, _server) = + spawn_sequenced_streaming_llm(vec![streaming_gateway_call_then_malformed_search_response()]).await; + let pool = create_pool_with_schema(Some("sqlite://?mode=memory")) + .await + .expect("storage schema"); + let calls = Arc::new(AtomicUsize::new(0)); + let context = Arc::new( + ExecutionContext::new( + ConversationHandler::new(ConversationStore::new(Arc::clone(&pool))), + ResponseHandler::new(ResponseStore::new(Arc::clone(&pool))), + Arc::new(reqwest::Client::new()), + llm_url, + ) + .with_gateway_executor(Arc::new(CountingWebSearch { + calls: Arc::clone(&calls), + })), + ); + let mut payload = request( + &json!("find weather"), + &json!([search_declaration(), {"type":"web_search_preview"}]), + ); + payload.stream = true; + payload.store = true; + + let events = run_streaming(payload, context).await; + + let failed = events.last().expect("response.failed"); + assert_eq!(failed["type"], "response.failed"); + assert!(events.iter().all(|event| event["type"] != "response.completed")); + assert_eq!(calls.load(Ordering::SeqCst), 0, "gateway call must not dispatch"); + let response_id = failed["response"]["id"].as_str().expect("failed response ID"); + let error = ResponseStore::new(pool) + .get(response_id) + .await + .expect_err("malformed streamed response must not persist"); + assert!(error.is_not_found()); +} + +#[tokio::test] +async fn function_only_nonstreaming_manual_three_request_flow() { + let (llm_url, requests, _server) = spawn_sequenced_llm(vec![ + json!({ + "id": "upstream_search", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_search_1", + "call_id": "call_search_1", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_weather", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_weather_1", + "call_id": "call_weather_1", + "name": "get_weather", + "arguments": "{\"city\":\"Paris\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_final", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "message", + "id": "msg_final", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "PARIS_WEATHER_OK", "annotations": []}] + }] + }), + ]) + .await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let user = json!({"type": "message", "role": "user", "content": "find weather"}); + + let first = run( + request( + &json!([user.clone()]), + &json!([search_declaration(), deferred_weather()]), + ), + Arc::clone(&context), + ) + .await; + let public_search_call = assert_public_search_call(&first); + + let public_search_output = json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [deferred_weather()] + }); + let second_input = json!([user.clone(), public_search_call.clone(), public_search_output.clone()]); + let second = run(request(&second_input, &json!([])), Arc::clone(&context)).await; + let OutputItem::FunctionCall(weather_call) = &second.output[0] else { + panic!("loaded client function remains an ordinary public function call") + }; + assert_eq!(weather_call.name, "get_weather"); + assert_eq!(weather_call.call_id, "call_weather_1"); + + let public_weather_call = serde_json::to_value(&second.output[0]).expect("function call serializes"); + let mut third_items = second_input.as_array().expect("item history").clone(); + third_items.push(public_weather_call); + third_items.push(json!({ + "type": "function_call_output", + "call_id": "call_weather_1", + "output": "sunny" + })); + let third = run(request(&Value::Array(third_items), &json!([])), context).await; + assert!(matches!(&third.output[0], OutputItem::Message(_))); + assert_eq!( + serde_json::to_value(&third.output[0]).unwrap()["content"][0]["text"], + "PARIS_WEATHER_OK" + ); + + assert_private_request_sequence(&requests.lock().await); +} + +#[tokio::test] +async fn deferred_mcp_load_uses_existing_discovery_lifecycle_and_dispatch_once() { + let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; + let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![ + json!({ + "id": "upstream_search", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_search_weather", + "call_id": "call_search_weather", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_mcp_call", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_forecast", + "call_id": "call_forecast", + "name": "mcp__weather__forecast", + "arguments": "{\"city\":\"Paris\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_final", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "message", + "id": "msg_final", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "MCP_WEATHER_OK", "annotations": []}] + }] + }), + ]) + .await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let deferred_mcp = deferred_weather_mcp(&mcp_url); + let user = json!({"type": "message", "role": "user", "content": "find weather"}); + let tools = json!([search_declaration(), deferred_mcp.clone()]); + + let search_response = run(request(&json!([user.clone()]), &tools), Arc::clone(&context)).await; + assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 0); + assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 0); + assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); + let public_search_call = serde_json::to_value(&search_response.output[0]).expect("search call serializes"); + assert_eq!(public_search_call["type"], "tool_search_call"); + assert_eq!(public_search_call["call_id"], "call_search_weather"); + + let payload = request( + &json!([ + user, + public_search_call, + { + "type": "tool_search_output", + "call_id": "call_search_weather", + "tools": [deferred_mcp.clone()] + } + ]), + &tools, + ); + + let response = run(payload, context).await; + + assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 1); + assert!(matches!(&response.output[0], OutputItem::McpListTools(_))); + let OutputItem::McpCall(call) = &response.output[1] else { + panic!("loaded MCP call must use the normal public MCP lifecycle") + }; + assert_eq!(call.server_label, "weather"); + assert_eq!(call.name, "forecast"); + assert_eq!(call.output.as_deref(), Some("sunny")); + assert!(matches!(&response.output[2], OutputItem::Message(_))); + + assert_mcp_private_request_sequence(&requests.lock().await, &mcp_url); +} + +#[tokio::test] +async fn deferred_and_dynamic_mcp_discovery_rejects_matching_history_call_before_load() { + for declared_before_search in [true, false] { + let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; + let (llm_url, requests, _llm_server) = spawn_sequenced_llm(Vec::new()).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let deferred_mcp = deferred_weather_mcp(&mcp_url); + let tools = if declared_before_search { + json!([search_declaration(), deferred_mcp.clone()]) + } else { + json!([search_declaration()]) + }; + let payload = request( + &json!([ + { + "type": "function_call", "id": "fc_early", "call_id": "call_early", + "name": "mcp__weather__forecast", "arguments": "{}", "status": "completed" + }, + {"type": "function_call_output", "call_id": "call_early", "output": "not executed"}, + { + "type": "tool_search_call", "id": "tsc_load", "call_id": "call_load", + "arguments": {"query": "weather"} + }, + {"type": "tool_search_output", "call_id": "call_load", "tools": [deferred_mcp]} + ]), + &tools, + ); + + let Err(error) = Box::pin(ExecuteRequest::new(payload, context).run()).await else { + panic!("an MCP function cannot be called before its server is loaded") + }; + + assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); + assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); + assert!(requests.lock().await.is_empty(), "inference must not run"); + } +} + +#[tokio::test] +async fn immediate_mcp_stays_available_before_an_identical_search_result() { + let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; + let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![json!({ + "id": "upstream_final", "object": "response", "status": "completed", "model": "test", + "created_at": 0, "output": [{ + "type": "message", "id": "msg_final", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "IMMEDIATE_MCP_OK", "annotations": []}] + }] + })]) + .await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let mut immediate_mcp = deferred_weather_mcp(&mcp_url); + immediate_mcp + .as_object_mut() + .expect("MCP declaration") + .remove("defer_loading"); + let payload = request( + &json!([ + { + "type": "function_call", "id": "fc_early", "call_id": "call_early", + "name": "mcp__weather__forecast", "arguments": "{}", "status": "completed" + }, + {"type": "function_call_output", "call_id": "call_early", "output": "already handled"}, + { + "type": "tool_search_call", "id": "tsc_identical", "call_id": "call_identical", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", "call_id": "call_identical", + "tools": [immediate_mcp.clone()] + } + ]), + &json!([search_declaration(), immediate_mcp]), + ); + + let response = run(payload, context).await; + + assert!(matches!(&response.output[1], OutputItem::Message(_))); + assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); + assert_eq!(requests.lock().await.len(), 1, "request must reach inference"); +} + +#[tokio::test] +async fn loaded_deferred_mcp_failure_uses_sanitized_public_list_tools_item() { + let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![json!({ + "id": "upstream_after_mcp_failure", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "message", + "id": "msg_final", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "MCP_FAILURE_HANDLED", "annotations": []}] + }] + })]) + .await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let deferred_mcp = json!({ + "type": "mcp", + "server_label": "private_weather", + "server_description": "Private weather server", + "server_url": "http://url-user:url-password@127.0.0.1:1/mcp?token=query-secret", + "headers": {"X-Private-Token": "header-secret"}, + "authorization": "authorization-secret", + "require_approval": "never", + "defer_loading": true + }); + let payload = request( + &json!([ + { + "type": "tool_search_call", + "id": "tsc_private_weather", + "call_id": "call_search_private_weather", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_private_weather", + "tools": [deferred_mcp.clone()] + } + ]), + &json!([search_declaration(), deferred_mcp]), + ); + + let response = run(payload, context).await; + + let OutputItem::McpListTools(list_tools) = &response.output[0] else { + panic!("loaded MCP configuration failure must retain public list-tools semantics") + }; + assert_eq!(list_tools.server_label, "private_weather"); + assert!(list_tools.tools.is_empty()); + assert_eq!( + list_tools.error.as_deref(), + Some("MCP server 'private_weather' failed to connect or list tools") + ); + assert!(matches!(&response.output[1], OutputItem::Message(_))); + + let public_response = serde_json::to_string(&response).expect("response serializes"); + let upstream_requests = requests.lock().await; + assert_eq!(upstream_requests.len(), 1); + let upstream_request = upstream_requests[0].to_string(); + for secret in [ + "url-user", + "url-password", + "query-secret", + "header-secret", + "authorization-secret", + ] { + assert!(!public_response.contains(secret), "public response leaked {secret}"); + assert!(!upstream_request.contains(secret), "upstream request leaked {secret}"); + } +} + +#[tokio::test] +async fn loaded_mcp_cannot_collide_with_a_still_withheld_function() { + let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; + let (llm_url, requests, _llm_server) = spawn_sequenced_llm(Vec::new()).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let deferred_mcp = deferred_weather_mcp(&mcp_url); + let payload = request( + &json!([ + { + "type": "tool_search_call", + "id": "tsc_collision", + "call_id": "call_search_collision", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_collision", + "tools": [deferred_mcp.clone()] + } + ]), + &json!([ + search_declaration(), + { + "type": "function", + "name": "mcp__weather__forecast", + "parameters": {"type": "object"}, + "defer_loading": true + }, + deferred_mcp + ]), + ); + + let Err(error) = Box::pin(ExecuteRequest::new(payload, context).run()).await else { + panic!("loaded MCP discovered name must not overwrite a declared function") + }; + + assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); + assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); + assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); + assert!(requests.lock().await.is_empty(), "collision must fail before inference"); +} + +#[tokio::test] +async fn function_only_nonstreaming_malformed_search_is_atomic_before_gateway_side_effects() { + for (case, malformed_search) in [ + ( + "non-object arguments", + json!({"arguments": "null", "namespace": null, "status": "completed"}), + ), + ( + "nonterminal status", + json!({"arguments": "{}", "namespace": null, "status": "in_progress"}), + ), + ( + "unexpected namespace", + json!({"arguments": "{}", "namespace": "tools", "status": "completed"}), + ), + ] { + let (llm_url, requests, _server) = spawn_sequenced_llm(vec![json!({ + "id": "upstream_invalid_mixed", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [ + { + "type": "function_call", + "id": "fc_web", + "call_id": "call_web", + "name": "web_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }, + { + "type": "function_call", + "id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "arguments": malformed_search["arguments"], + "namespace": malformed_search["namespace"], + "status": malformed_search["status"] + } + ] + })]) + .await; + let calls = Arc::new(AtomicUsize::new(0)); + let context = Arc::new( + ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + ) + .with_gateway_executor(Arc::new(CountingWebSearch { + calls: Arc::clone(&calls), + })), + ); + let request = request( + &json!("find weather"), + &json!([search_declaration(), {"type": "web_search_preview"}]), + ); + + let Err(error) = ExecuteRequest::new(request, context).run().await else { + panic!("{case}: malformed reserved call must reject the whole response") + }; + + assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY, "{case}"); + assert_eq!(error.error_type(), "tool_error", "{case}"); + assert_eq!(calls.load(Ordering::SeqCst), 0, "{case}: gateway call must not execute"); + assert_eq!(requests.lock().await.len(), 1, "{case}: only inference may run"); + } +} + +#[tokio::test] +async fn declaration_free_replay_malformed_search_is_atomic_before_gateway_side_effects() { + let (llm_url, requests, _server) = spawn_sequenced_llm(vec![json!({ + "id": "upstream_invalid_replay", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [ + { + "type": "function_call", "id": "fc_web", "call_id": "call_web", + "name": "web_search", "arguments": "{\"query\":\"weather\"}", "status": "completed" + }, + { + "type": "function_call", "id": "fc_search", "call_id": "call_search", + "name": "tool_search", "namespace": null, "arguments": "{}", "status": "in_progress" + } + ] + })]) + .await; + let calls = Arc::new(AtomicUsize::new(0)); + let context = Arc::new( + ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + ) + .with_gateway_executor(Arc::new(CountingWebSearch { + calls: Arc::clone(&calls), + })), + ); + let request = request(&completed_search_replay(), &json!([{"type": "web_search_preview"}])); + + let Err(error) = Box::pin(ExecuteRequest::new(request, context).run()).await else { + panic!("malformed declaration-free replay call must reject the whole response") + }; + assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY); + assert_eq!(calls.load(Ordering::SeqCst), 0, "gateway call must not execute"); + assert_eq!(requests.lock().await.len(), 1, "only inference may run"); +} + +#[tokio::test] +async fn namespace_nonstreaming_manual_flow_reuses_flattening_and_restoration() { + let namespace = weather_namespace(); + let namespace_name = namespace["name"].as_str().expect("namespace name"); + let member_name = namespace["tools"][1]["name"].as_str().expect("member name"); + let flat_name = model_visible_namespace_member_name(namespace_name, member_name); + assert!(flat_name.len() <= 64); + let (llm_url, requests, _server) = spawn_sequenced_llm(vec![ + json!({ + "id": "upstream_search", "object": "response", "status": "completed", "model": "test", + "created_at": 0, "output": [{ + "type": "function_call", "id": "fc_search", "call_id": "call_search", + "name": "tool_search", "namespace": null, "arguments": "{\"query\":\"forecast\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_namespace", "object": "response", "status": "completed", "model": "test", + "created_at": 0, "output": [{ + "type": "function_call", "id": "fc_forecast", "call_id": "call_forecast", + "name": flat_name, "namespace": null, "arguments": "{\"city\":\"Paris\"}", + "status": "completed" + }] + }), + json!({ + "id": "upstream_final", "object": "response", "status": "completed", "model": "test", + "created_at": 0, "output": [{ + "type": "message", "id": "msg_final", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "NAMESPACE_OK", "annotations": []}] + }] + }), + ]) + .await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + let user = json!({"type": "message", "role": "user", "content": "find forecast"}); + let first = run( + request( + &json!([user.clone()]), + &json!([search_declaration(), namespace.clone()]), + ), + Arc::clone(&context), + ) + .await; + let public_search_call = serde_json::to_value(&first.output[0]).expect("search call serializes"); + let public_search_output = json!({ + "type": "tool_search_output", "call_id": "call_search", "execution": "client", + "status": "completed", "tools": [loaded_weather_namespace_subset()] + }); + let second_input = json!([user.clone(), public_search_call, public_search_output]); + let second = run( + request(&second_input, &json!([namespace.clone()])), + Arc::clone(&context), + ) + .await; + let OutputItem::FunctionCall(call) = &second.output[0] else { + panic!("loaded namespace member remains a client function call") + }; + assert_eq!(call.namespace.as_deref(), Some(namespace_name)); + assert_eq!(call.name, member_name); + assert_ne!(call.name, flat_name, "private flat name must not leak publicly"); + + let mut third_items = second_input.as_array().expect("history").clone(); + third_items.push(serde_json::to_value(&second.output[0]).expect("namespace call serializes")); + third_items.push(json!({ + "type": "function_call_output", "call_id": "call_forecast", "output": "sunny" + })); + let third = run(request(&Value::Array(third_items), &json!([namespace])), context).await; + assert!(matches!(&third.output[0], OutputItem::Message(_))); + + let requests = requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[1]["tools"].as_array().map(Vec::len), Some(2)); + assert!( + requests[1]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == flat_name) + ); + assert_eq!(requests[2]["input"][3]["name"], flat_name); + assert!(requests[2]["input"][3].get("namespace").is_none()); +} + +#[tokio::test] +async fn withheld_function_calls_fail_before_gateway_side_effects() { + let namespace = weather_namespace(); + let flat_name = model_visible_namespace_member_name( + namespace["name"].as_str().unwrap(), + namespace["tools"][1]["name"].as_str().unwrap(), + ); + for (name, tool) in [("get_weather".to_owned(), deferred_weather()), (flat_name, namespace)] { + let (llm_url, requests, _server) = spawn_sequenced_llm(vec![json!({ + "id": "upstream_withheld", "object": "response", "status": "completed", "model": "test", + "created_at": 0, "output": [ + { + "type": "function_call", "id": "fc_web", "call_id": "call_web", "name": "web_search", + "arguments": "{\"query\":\"weather\"}", "status": "completed" + }, + { + "type": "function_call", "id": "fc_withheld", "call_id": "call_withheld", "name": name, + "namespace": null, "arguments": "{}", "status": "completed" + } + ] + })]) + .await; + let calls = Arc::new(AtomicUsize::new(0)); + let context = Arc::new( + ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + ) + .with_gateway_executor(Arc::new(CountingWebSearch { + calls: Arc::clone(&calls), + })), + ); + let payload = request( + &json!("find weather"), + &json!([search_declaration(), tool, {"type": "web_search_preview"}]), + ); + + let Err(error) = Box::pin(ExecuteRequest::new(payload, context).run()).await else { + panic!("known withheld function call must reject the whole response") + }; + assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY); + assert_eq!(calls.load(Ordering::SeqCst), 0, "gateway call must not execute"); + assert_eq!(requests.lock().await.len(), 1, "only inference may run"); + } +} + +#[tokio::test] +async fn withheld_namespace_history_calls_fail_before_inference() { + let namespace = weather_namespace(); + let namespace_name = namespace["name"].as_str().expect("namespace name"); + let member_name = namespace["tools"][1]["name"].as_str().expect("member name"); + let flat_name = model_visible_namespace_member_name(namespace_name, member_name); + let (llm_url, requests, _server) = spawn_sequenced_llm(Vec::new()).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + + for call in [ + json!({ + "type": "function_call", "id": "fc_public", "call_id": "call_public", + "namespace": namespace_name, "name": member_name, "arguments": "{}", "status": "completed" + }), + json!({ + "type": "function_call", "id": "fc_flat", "call_id": "call_flat", + "name": flat_name, "arguments": "{}", "status": "completed" + }), + ] { + let payload = request(&json!([call]), &json!([search_declaration(), namespace.clone()])); + let Err(error) = Box::pin(ExecuteRequest::new(payload, Arc::clone(&context)).run()).await else { + panic!("withheld history call must fail before inference") + }; + assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); + } + assert!(requests.lock().await.is_empty(), "inference must not run"); +} + +#[tokio::test] +async fn dynamic_namespace_forward_references_fail_before_inference() { + let namespace = weather_namespace(); + let namespace_name = namespace["name"].as_str().expect("namespace name"); + let member_name = namespace["tools"][1]["name"].as_str().expect("member name"); + let flat_name = model_visible_namespace_member_name(namespace_name, member_name); + let loaded_subset = loaded_weather_namespace_subset(); + let (llm_url, requests, _server) = spawn_sequenced_llm(Vec::new()).await; + let context = Arc::new(ExecutionContext::new( + ConversationHandler::new(ConversationStore::disabled()), + ResponseHandler::new(ResponseStore::disabled()), + Arc::new(reqwest::Client::new()), + llm_url, + )); + + for call in [ + json!({ + "type": "function_call", "id": "fc_public", "call_id": "call_public", + "namespace": namespace_name, "name": member_name, "arguments": "{}", "status": "completed" + }), + json!({ + "type": "function_call", "id": "fc_flat", "call_id": "call_flat", + "name": flat_name, "arguments": "{}", "status": "completed" + }), + ] { + let payload = request( + &json!([ + call, + { + "type": "tool_search_call", "id": "tsc_dynamic", "call_id": "call_dynamic", + "arguments": {"query": "forecast"} + }, + { + "type": "tool_search_output", "call_id": "call_dynamic", + "tools": [loaded_subset.clone()] + } + ]), + &json!([search_declaration()]), + ); + let Err(error) = Box::pin(ExecuteRequest::new(payload, Arc::clone(&context)).run()).await else { + panic!("namespace forward reference must fail before inference") + }; + assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); + } + assert!(requests.lock().await.is_empty(), "inference must not run"); +} + +fn visit_recorded_values(value: &Value, visitor: &mut impl FnMut(&serde_json::Map)) { + match value { + Value::Object(object) => { + visitor(object); + for child in object.values() { + visit_recorded_values(child, visitor); + } + } + Value::Array(array) => { + for child in array { + visit_recorded_values(child, visitor); + } + } + Value::String(text) => { + if matches!(text.trim().as_bytes().first(), Some(b'{' | b'[')) + && let Ok(decoded) = serde_json::from_str::(text) + { + visit_recorded_values(&decoded, visitor); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn validate_gateway_cassette_has_no_private_search_projection(raw: &Value) -> Result<(), String> { + let mut public_search_call_ids = HashSet::new(); + let mut search_item_ids = HashSet::new(); + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + if item_type == Some("tool_search_call") { + if let Some(call_id) = object.get("call_id").and_then(Value::as_str) { + public_search_call_ids.insert(call_id.to_owned()); + } + if let Some(item_id) = object.get("id").and_then(Value::as_str) { + search_item_ids.insert(item_id.to_owned()); + } + } else if item_type == Some("function_call") + && object.get("name").and_then(Value::as_str) == Some("tool_search") + && let Some(item_id) = object.get("id").and_then(Value::as_str) + { + search_item_ids.insert(item_id.to_owned()); + } + }); + + let mut violation = None; + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + let name = object.get("name").and_then(Value::as_str); + if matches!(item_type, Some("function" | "function_call")) && name == Some("tool_search") { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked the private synthetic search projection: {object:?}") + }); + } else if item_type == Some("function_call_output") { + let call_id = object.get("call_id").and_then(Value::as_str); + if call_id.is_some_and(|call_id| public_search_call_ids.contains(call_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked a normalized search function output: {object:?}") + }); + } + } else if matches!( + item_type, + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) { + let item_id = object.get("item_id").and_then(Value::as_str); + if item_id.is_some_and(|item_id| search_item_ids.contains(item_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked normalized search argument events: {object:?}") + }); + } + } + }); + violation.map_or(Ok(()), Err) +} + +#[test] +fn provider_parity_matrix_is_exact_and_gateway_has_no_private_search_leaks() { + const FLOW_CASSETTES: [(&str, bool); 7] = [ + ("tool-search-openai-reference-gpt-5.6-nonstreaming.yaml", false), + ("tool-search-openai-reference-gpt-5.6-streaming.yaml", false), + ( + "tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", + false, + ), + ("tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", false), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", true), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", true), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml", true), + ]; + + let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); + let expected_names = FLOW_CASSETTES + .iter() + .map(|(filename, _)| (*filename).to_owned()) + .collect::>(); + let actual_names = fs::read_dir(&directory) + .expect("tool-search cassette directory") + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { + return None; + } + let cassette = support::load_cassette(path.to_str().expect("cassette path")); + (cassette.turns.len() == 3).then(|| { + path.file_name() + .and_then(|filename| filename.to_str()) + .expect("cassette filename") + .to_owned() + }) + }) + .collect::>(); + assert_eq!( + actual_names, expected_names, + "the final seven-cassette flow matrix must be exact" + ); + + for (filename, gateway) in FLOW_CASSETTES { + let path = directory.join(filename); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&path).expect("cassette metadata").permissions().mode() & 0o777, + 0o664, + "generated cassette mode drift in {filename}" + ); + } + if gateway { + let raw = + serde_yaml::from_str::(&fs::read_to_string(&path).expect("gateway cassette should be readable")) + .expect("gateway cassette YAML"); + let raw_turns = raw["turns"].as_array().expect("raw gateway cassette turns"); + let cassette = support::load_cassette(path.to_str().expect("gateway cassette path")); + let mut decoded_surfaces = Vec::new(); + for (raw_turn, turn) in raw_turns.iter().zip(&cassette.turns) { + decoded_surfaces.push(raw_turn["request"]["body"].clone()); + if let Some(body) = &turn.response.body { + decoded_surfaces.push(body.clone()); + } + if turn.response.sse.is_some() { + decoded_surfaces.extend(support::recorded_named_sse_events(turn)); + } + if let Some(websocket) = raw_turn["response"]["websocket"].as_array() { + decoded_surfaces.extend(websocket.iter().cloned()); + } + } + validate_gateway_cassette_has_no_private_search_projection(&Value::Array(decoded_surfaces)) + .unwrap_or_else(|error| panic!("{error}")); + } + } +} + +#[test] +fn provider_parity_non_leak_detector_rejects_nested_private_shapes() { + let cases = [ + json!({"response": {"tools": [{"type": "function", "name": "tool_search"}]}}), + json!({"response": {"output": [{ + "type": "function_call", "id": "fc_private", "call_id": "call_search", "name": "tool_search" + }]}}), + json!({ + "request": {"input": [{ + "type": "tool_search_call", "id": "tsc_public", "call_id": "call_search" + }, { + "type": "function_call_output", "call_id": "call_search", "output": "{}" + }]} + }), + json!([{ + "type": "response.output_item.added", + "item": {"type": "tool_search_call", "id": "tsc_public", "call_id": "call_search"} + }, { + "type": "response.function_call_arguments.delta", "item_id": "tsc_public", "delta": "{}" + }]), + json!([r#"{"type":"function_call","id":"fc_ws","call_id":"call_ws","name":"tool_search"}"#]), + ]; + + for case in cases { + assert!( + validate_gateway_cassette_has_no_private_search_projection(&case).is_err(), + "private nested shape should be rejected: {case}" + ); + } +} diff --git a/crates/agentic-server/tests/responses_test.rs b/crates/agentic-server/tests/responses_test.rs index e79108e4..3ab5f336 100644 --- a/crates/agentic-server/tests/responses_test.rs +++ b/crates/agentic-server/tests/responses_test.rs @@ -6,11 +6,14 @@ use axum::http::header; use axum::response::IntoResponse; use axum::routing::post; use http::StatusCode; +use std::collections::VecDeque; use std::convert::Infallible; +use std::fmt::Write as _; use std::future::Future; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::task::{Context, Poll}; use tokio::net::TcpListener; use tokio::sync::{Mutex, oneshot}; @@ -360,22 +363,35 @@ async fn spawn_mock_vllm_json() -> (String, tokio::task::JoinHandle<()>) { } async fn spawn_mock_vllm_json_capture() -> (String, Arc>>, tokio::task::JoinHandle<()>) { + spawn_mock_vllm_json_capture_body(serde_json::json!({ + "id": "mock_id", + "object": "response", + "status": "completed", + "model": "test", + "output": [], + "created_at": 0 + })) + .await +} + +async fn spawn_mock_vllm_json_capture_body( + response_body: serde_json::Value, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { let requests = Arc::new(Mutex::new(Vec::new())); let route_requests = Arc::clone(&requests); + let response_body = Arc::new(response_body.to_string()); let app = Router::new().route( "/v1/responses", post(move |body: Bytes| { let route_requests = Arc::clone(&route_requests); + let response_body = Arc::clone(&response_body); async move { let body = serde_json::from_slice::(&body).unwrap(); route_requests.lock().await.push(body); axum::response::Response::builder() .status(200) .header("Content-Type", "application/json") - .body(axum::body::Body::from( - r#"{"id":"mock_id","object":"response","status":"completed", - "model":"test","output":[],"created_at":0}"#, - )) + .body(axum::body::Body::from(response_body.as_str().to_owned())) .unwrap() .into_response() } @@ -387,6 +403,25 @@ async fn spawn_mock_vllm_json_capture() -> (String, Arc (String, Arc, tokio::task::JoinHandle<()>) { + let requests = Arc::new(AtomicUsize::new(0)); + let route_requests = Arc::clone(&requests); + let app = Router::new().route( + "/mcp", + post(move || { + let route_requests = Arc::clone(&route_requests); + async move { + route_requests.fetch_add(1, Ordering::SeqCst); + axum::Json(serde_json::json!({})) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}/mcp"), requests, handle) +} + /// Spawn a mock vLLM that returns an SSE stream. async fn spawn_mock_vllm_sse() -> (String, tokio::task::JoinHandle<()>) { let app = Router::new().route( @@ -408,6 +443,284 @@ async fn spawn_mock_vllm_sse() -> (String, tokio::task::JoinHandle<()>) { (format!("http://{addr}"), handle) } +async fn spawn_tool_search_sse_sequence( + responses: Vec, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let responses = Arc::new(Mutex::new(VecDeque::from(responses))); + let requests = Arc::new(Mutex::new(Vec::new())); + let route_responses = Arc::clone(&responses); + let route_requests = Arc::clone(&requests); + let app = Router::new().route( + "/v1/responses", + post(move |body: Bytes| { + let route_responses = Arc::clone(&route_responses); + let route_requests = Arc::clone(&route_requests); + async move { + route_requests + .lock() + .await + .push(serde_json::from_slice(&body).expect("request JSON")); + let response = route_responses.lock().await.pop_front().expect("prepared SSE response"); + axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") + .body(axum::body::Body::from(response)) + .unwrap() + .into_response() + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), requests, handle) +} + +fn tool_search_sse() -> String { + let events = [ + serde_json::json!({ + "type":"response.created", + "response":{ + "id":"up_search","status":"in_progress", + "tools":[{"type":"function","name":"tool_search","parameters":{"type":"object"}}] + } + }), + serde_json::json!({ + "type":"response.output_item.added","output_index":0, + "item":{"id":"fc_search","type":"function_call","status":"in_progress", + "name":"tool_search","call_id":"call_search","arguments":""} + }), + serde_json::json!({ + "type":"response.function_call_arguments.delta","output_index":0, + "item_id":"fc_search","delta":"{\"query\":\"weather\"}" + }), + serde_json::json!({ + "type":"response.function_call_arguments.done","output_index":0, + "item_id":"fc_search","name":"tool_search","arguments":"{\"query\":\"weather\"}" + }), + serde_json::json!({ + "type":"response.output_item.done","output_index":0, + "item":{"id":"fc_search","type":"function_call","status":"completed", + "name":"tool_search","call_id":"call_search","arguments":"{\"query\":\"weather\"}"} + }), + serde_json::json!({"type":"response.completed","response":{"id":"up_search","status":"completed","usage":null}}), + ]; + encode_sse_events(events) +} + +fn encode_sse_events(events: impl IntoIterator) -> String { + let mut response = String::new(); + for event in events { + writeln!(&mut response, "data: {event}\n").expect("writing to String cannot fail"); + } + response.push_str("data: [DONE]\n\n"); + response +} + +fn function_call_sse(name: &str, item_id: &str, call_id: &str, arguments: &str) -> String { + let events = [ + serde_json::json!({ + "type":"response.created", + "response":{ + "id":"up_call","status":"in_progress", + "tools":[{"type":"function","name":name,"parameters":{"type":"object"}}] + } + }), + serde_json::json!({ + "type":"response.output_item.added","output_index":0, + "item":{"id":item_id,"type":"function_call","status":"in_progress", + "name":name,"call_id":call_id,"arguments":""} + }), + serde_json::json!({ + "type":"response.output_item.done","output_index":0, + "item":{"id":item_id,"type":"function_call","status":"completed", + "name":name,"call_id":call_id,"arguments":arguments} + }), + serde_json::json!({"type":"response.completed","response":{"id":"up_call","status":"completed","usage":null}}), + ]; + encode_sse_events(events) +} + +fn final_message_sse() -> String { + let events = [ + serde_json::json!({"type":"response.created","response":{"id":"up_final","status":"in_progress"}}), + serde_json::json!({ + "type":"response.output_item.added","output_index":0, + "item":{"id":"msg_final","type":"message","role":"assistant","status":"in_progress","content":[]} + }), + serde_json::json!({ + "type":"response.output_text.delta","output_index":0,"content_index":0, + "item_id":"msg_final","delta":"PARIS_WEATHER_OK" + }), + serde_json::json!({"type":"response.completed","response":{"id":"up_final","status":"completed","usage":null}}), + ]; + encode_sse_events(events) +} + +fn assert_public_search_sse( + first_events: &[serde_json::Value], + deferred_weather: &serde_json::Value, +) -> serde_json::Value { + let public_tools = serde_json::json!([ + { + "type":"tool_search","execution":"client","description":"Search tools", + "parameters":{"type":"object","properties":{"query":{"type":"string"}}} + }, + deferred_weather + ]); + let response_tool_envelopes = first_events + .iter() + .filter_map(|event| event.get("response")) + .filter_map(|response| response.get("tools")) + .collect::>(); + assert!(!response_tool_envelopes.is_empty()); + assert!(response_tool_envelopes.iter().all(|tools| *tools == &public_tools)); + assert!(first_events.iter().all(|event| { + event["response"]["tools"].as_array().is_none_or(|tools| { + tools + .iter() + .all(|tool| !(tool["type"] == "function" && tool["name"] == "tool_search")) + }) + })); + assert_eq!( + first_events + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(first_events.len()).unwrap()) + .map(Some) + .collect::>() + ); + assert!(first_events.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done" | "error") + ) + })); + let search_lifecycle = first_events + .iter() + .filter(|event| { + matches!( + event["type"].as_str(), + Some("response.output_item.added" | "response.output_item.done") + ) + }) + .collect::>(); + assert_eq!(search_lifecycle.len(), 2); + assert_eq!(search_lifecycle[0]["item"]["type"], "tool_search_call"); + assert_eq!(search_lifecycle[0]["item"]["status"], "in_progress"); + assert_eq!(search_lifecycle[0]["item"]["arguments"], serde_json::json!({})); + assert_eq!(search_lifecycle[1]["item"]["type"], "tool_search_call"); + assert_eq!(search_lifecycle[1]["item"]["status"], "completed"); + assert_eq!( + search_lifecycle[1]["item"]["arguments"], + serde_json::json!({"query":"weather"}) + ); + assert_eq!(search_lifecycle[0]["item"]["id"], search_lifecycle[1]["item"]["id"]); + assert_eq!( + search_lifecycle[0]["item"]["call_id"], + search_lifecycle[1]["item"]["call_id"] + ); + assert_eq!(search_lifecycle[0]["output_index"], search_lifecycle[1]["output_index"]); + let search_call = first_events.last().expect("first terminal")["response"]["output"][0].clone(); + assert_eq!(search_call["type"], "tool_search_call"); + assert_eq!(search_call["id"], "tsc_search"); + assert_eq!(search_call, search_lifecycle[1]["item"]); + search_call +} + +#[tokio::test] +async fn test_http_sse_tool_search_three_request_continuation_stays_public() { + let (llm_url, requests, _llm) = spawn_tool_search_sse_sequence(vec![ + tool_search_sse(), + function_call_sse("get_weather", "fc_weather", "call_weather", "{\"city\":\"Paris\"}"), + final_message_sse(), + ]) + .await; + let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; + let client = reqwest::Client::new(); + let deferred_weather = serde_json::json!({ + "type":"function","name":"get_weather","description":"Get weather", + "parameters":{"type":"object","properties":{"city":{"type":"string"}}}, + "defer_loading":true + }); + let first_response = client + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model":"test","input":"find weather","store":false,"stream":true,"parallel_tool_calls":false, + "tools":[ + {"type":"tool_search","execution":"client","description":"Search tools", + "parameters":{"type":"object","properties":{"query":{"type":"string"}}}}, + deferred_weather.clone() + ] + })) + .send() + .await + .expect("first response"); + assert_eq!(first_response.status(), StatusCode::OK); + let first_body = first_response.text().await.expect("first SSE body"); + let first_events = sse_events(&first_body); + let search_call = assert_public_search_sse(&first_events, &deferred_weather); + + let search_output = serde_json::json!({ + "type":"tool_search_output","call_id":"call_search","tools":[deferred_weather] + }); + let second_response = client + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model":"test","input":[search_call.clone(),search_output.clone()],"store":false,"stream":true + })) + .send() + .await + .expect("second response"); + let second_body = second_response.text().await.expect("second SSE body"); + let second_events = sse_events(&second_body); + let weather_call = second_events.last().unwrap()["response"]["output"][0].clone(); + assert_eq!(weather_call["type"], "function_call"); + assert_eq!(weather_call["name"], "get_weather"); + + let third_response = client + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model":"test", + "input":[ + search_call,search_output,weather_call, + {"type":"function_call_output","call_id":"call_weather","output":"sunny"} + ], + "store":false,"stream":true + })) + .send() + .await + .expect("third response"); + let third_events = sse_events(&third_response.text().await.expect("third SSE body")); + assert_eq!( + third_events.last().unwrap()["response"]["output"][0]["content"][0]["text"], + "PARIS_WEATHER_OK" + ); + + let requests = requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(requests[0]["tools"][0]["name"], "tool_search"); + assert!( + requests[1]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_weather") + ); + assert!(requests[1]["input"].as_array().unwrap().iter().any(|item| { + item["type"] == "function_call" && item["name"] == "tool_search" && item["call_id"] == "call_search" + })); + assert!( + requests[2]["input"] + .as_array() + .unwrap() + .iter() + .any(|item| { item["type"] == "function_call_output" && item["call_id"] == "call_weather" }) + ); +} + #[tokio::test] async fn test_store_false_proxies_json_to_vllm() { // Arrange @@ -428,6 +741,255 @@ async fn test_store_false_proxies_json_to_vllm() { assert_eq!(body["id"], "mock_id"); } +#[tokio::test] +async fn test_store_false_manual_tool_search_replay_loads_returned_function() { + let (llm_url, requests, _llm) = spawn_mock_vllm_json_capture_body(serde_json::json!({ + "id": "upstream_loaded_call", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_weather_1", + "call_id": "call_weather_1", + "name": "get_weather", + "arguments": "{\"city\":\"Paris\"}", + "status": "completed" + }] + })) + .await; + let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model": "test", + "input": [ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + }, + "defer_loading": true + }] + } + ], + "tools": [], + "store": false, + "stream": false + })) + .send() + .await + .expect("gateway response"); + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("response JSON"); + assert_eq!(body["output"][0]["type"], "function_call"); + assert_eq!(body["output"][0]["name"], "get_weather"); + + let requests = requests.lock().await; + assert_eq!(requests.len(), 1); + assert!(requests[0].get("previous_response_id").is_none()); + assert_eq!(requests[0]["input"][0]["type"], "function_call"); + assert_eq!(requests[0]["input"][0]["name"], "tool_search"); + assert_eq!(requests[0]["input"][0]["call_id"], "call_search_1"); + assert_eq!(requests[0]["input"][1]["type"], "function_call_output"); + assert_eq!(requests[0]["input"][1]["call_id"], "call_search_1"); + assert_eq!(requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(requests[0]["tools"][0]["name"], "get_weather"); + assert!(requests[0]["tools"][0].get("defer_loading").is_none()); +} + +#[tokio::test] +async fn test_store_false_fresh_tool_search_lowers_and_translates_blocking_call() { + let (llm_url, requests, _llm) = spawn_mock_vllm_json_capture_body(serde_json::json!({ + "id": "upstream_search_call", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_search_1", + "call_id": "call_search_1", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }] + })) + .await; + let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model": "test", + "input": "find a weather tool", + "tools": [ + { + "type": "tool_search", + "execution": "client", + "description": "Search the client catalog", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }, + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object"}, + "defer_loading": true + } + ], + "store": false, + "stream": false + })) + .send() + .await + .expect("gateway response"); + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("response JSON"); + assert_eq!( + body["output"][0], + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_search_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }) + ); + + let requests = requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(requests[0]["tools"][0]["type"], "function"); + assert_eq!(requests[0]["tools"][0]["name"], "tool_search"); + assert!( + requests[0]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().all(|tool| tool["name"] != "get_weather")), + "deferred function schema must not be a top-level private tool" + ); + assert!( + requests[0]["tools"][0]["description"] + .as_str() + .is_some_and(|description| description.contains("get_weather")), + "safe synthetic catalog keeps function identity" + ); +} + +#[tokio::test] +async fn test_blocking_tool_search_rejects_invalid_upstream_arguments_as_bad_gateway() { + let (llm_url, _requests, _llm) = spawn_mock_vllm_json_capture_body(serde_json::json!({ + "id": "upstream_bad_search_call", + "object": "response", + "status": "completed", + "model": "test", + "created_at": 0, + "output": [{ + "type": "function_call", + "id": "fc_search_bad", + "call_id": "call_search_bad", + "name": "tool_search", + "arguments": "[]", + "status": "completed" + }] + })) + .await; + let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model": "test", + "input": "find a tool", + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Search", + "parameters": {"type": "object"} + }], + "store": false, + "stream": false + })) + .send() + .await + .expect("gateway response"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body: serde_json::Value = response.json().await.expect("error JSON"); + assert_eq!(body["error"]["type"], "tool_error"); +} + +#[tokio::test] +async fn test_deferred_mcp_stays_withheld_before_valid_load() { + let (llm_url, upstream_requests, _llm) = spawn_mock_vllm_json_capture().await; + let (mcp_url, mcp_requests, _mcp) = spawn_counting_mcp_server().await; + let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/v1/responses")) + .json(&serde_json::json!({ + "model": "test", + "input": "find a tool", + "tools": [ + { + "type": "tool_search", + "execution": "client", + "description": "Search the client catalog", + "parameters": {"type": "object"} + }, + { + "type": "mcp", + "server_label": "deferred_server", + "server_url": mcp_url, + "server_description": "Deferred private tools", + "defer_loading": true + } + ], + "store": false, + "stream": false + })) + .send() + .await + .expect("gateway response"); + + assert_eq!(response.status(), StatusCode::OK); + let upstream_requests = upstream_requests.lock().await; + assert_eq!(upstream_requests.len(), 1, "the synthetic search may reach upstream"); + assert_eq!(upstream_requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(upstream_requests[0]["tools"][0]["name"], "tool_search"); + assert!( + !upstream_requests[0].to_string().contains(&mcp_url), + "deferred MCP endpoint must not enter the private upstream request" + ); + assert_eq!( + mcp_requests.load(Ordering::SeqCst), + 0, + "deferred MCP must not be connected or listed" + ); +} + #[tokio::test] async fn test_store_false_with_web_search_reaches_executor() { // Arrange diff --git a/crates/agentic-server/tests/responses_websocket_test.rs b/crates/agentic-server/tests/responses_websocket_test.rs index a9d5b8ac..697dbc30 100644 --- a/crates/agentic-server/tests/responses_websocket_test.rs +++ b/crates/agentic-server/tests/responses_websocket_test.rs @@ -585,6 +585,86 @@ fn sse_function_call_response(response_id: &str, call_name: &str) -> String { format!("data: {created}\n\ndata: {added}\n\ndata: {done}\n\ndata: {completed}\n\ndata: [DONE]\n\n") } +fn sse_tool_search_call_response() -> String { + let created = json!({ + "type": "response.created", + "response": { + "id": "resp_search", "status": "in_progress", + "tools": [{"type": "function", "name": "tool_search", "parameters": {"type": "object"}}] + } + }); + let in_progress = json!({ + "type": "response.in_progress", + "response": { + "id": "resp_search", "status": "in_progress", + "tools": [{"type": "function", "name": "tool_search", "parameters": {"type": "object"}}] + } + }); + let added = json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_search", "type": "function_call", "status": "in_progress", + "name": "tool_search", "call_id": "call_search", "arguments": ""} + }); + let delta = json!({ + "type": "response.function_call_arguments.delta", "output_index": 0, + "item_id": "fc_search", "delta": "{\"query\":\"weather\"}" + }); + let arguments_done = json!({ + "type": "response.function_call_arguments.done", "output_index": 0, + "item_id": "fc_search", "name": "tool_search", "arguments": "{\"query\":\"weather\"}" + }); + let done = json!({ + "type": "response.output_item.done", "output_index": 0, + "item": {"id": "fc_search", "type": "function_call", "status": "completed", + "name": "tool_search", "call_id": "call_search", "arguments": "{\"query\":\"weather\"}"} + }); + let completed = json!({ + "type": "response.completed", "response": {"id": "resp_search", "status": "completed", "usage": null} + }); + format!( + "data: {created}\n\ndata: {in_progress}\n\ndata: {added}\n\ndata: {delta}\n\ndata: {arguments_done}\n\ndata: {done}\n\ndata: {completed}\n\ndata: [DONE]\n\n" + ) +} + +fn sse_weather_function_call_response() -> String { + let created = json!({ + "type": "response.created", "response": {"id": "resp_weather", "status": "in_progress"} + }); + let added = json!({ + "type": "response.output_item.added", "output_index": 0, + "item": {"id": "fc_weather", "type": "function_call", "status": "in_progress", + "name": "get_weather", "call_id": "call_weather", "arguments": ""} + }); + let done = json!({ + "type": "response.output_item.done", "output_index": 0, + "item": {"id": "fc_weather", "type": "function_call", "status": "completed", + "name": "get_weather", "call_id": "call_weather", "arguments": "{\"city\":\"Paris\"}"} + }); + let completed = json!({ + "type": "response.completed", "response": {"id": "resp_weather", "status": "completed", "usage": null} + }); + format!("data: {created}\n\ndata: {added}\n\ndata: {done}\n\ndata: {completed}\n\ndata: [DONE]\n\n") +} + +fn tool_search_declaration() -> Value { + json!({ + "type": "tool_search", + "execution": "client", + "description": "Search the client catalog", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}} + }) +} + +fn deferred_weather_function() -> Value { + json!({ + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, + "defer_loading": true + }) +} + fn sse_custom_tool_call_response() -> String { let created = json!({ "type": "response.created", @@ -796,6 +876,9 @@ async fn test_websocket_generate_false_prewarm_redacts_mcp_runtime_credentials() let lookup_ctx = RequestContext { original_request: request.clone(), enriched_request: request, + tool_search_state: None, + tool_search_private_request: None, + tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_lookup".to_owned(), conversation_id: None, @@ -817,6 +900,295 @@ async fn test_websocket_generate_false_prewarm_redacts_mcp_runtime_credentials() assert!(tool.authorization.is_none()); } +#[tokio::test] +async fn test_websocket_generate_false_rejects_mismatched_search_output_without_persistence() { + let mock = MockResponsesServer::start(vec![]).await; + let fixture = storage_backed_state(&mock.url).await; + let (gateway_url, _gateway) = spawn_gateway(fixture.state.clone()).await; + let mut ws = connect_responses_ws(&gateway_url).await; + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "input": [ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", + "call_id": "call_mismatch", + "tools": [{ + "type": "mcp", + "server_label": "private", + "server_url": "https://mcp.example.test/mcp", + "headers": {"Authorization": "Bearer must-not-persist"} + }] + } + ], + "tools": [], + "generate": false, + "store": false, + "stream": true + }), + ) + .await; + + let events = recv_until_completed(&mut ws).await; + let error = events.last().expect("terminal tool-search error"); + assert_eq!(error["type"], "error"); + assert_eq!(error["status"], StatusCode::BAD_REQUEST.as_u16()); + assert_eq!(error["error"]["type"], "invalid_request_error"); + assert!(mock.request_bodies().await.is_empty()); + + let response_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM responses") + .fetch_one(fixture.pool.as_ref()) + .await + .expect("response count"); + let item_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items") + .fetch_one(fixture.pool.as_ref()) + .await + .expect("item count"); + assert_eq!(response_count, 0, "fail-closed request must not persist a response"); + assert_eq!(item_count, 0, "fail-closed request must not persist tool-search items"); +} + +fn assert_public_ws_search_lifecycle(first: &[Value]) -> String { + let expected_public_tools = json!([tool_search_declaration(), deferred_weather_function()]); + let response_tool_envelopes = first + .iter() + .filter_map(|event| event.get("response")) + .filter_map(|response| response.get("tools")) + .collect::>(); + assert!(!response_tool_envelopes.is_empty()); + assert!( + response_tool_envelopes + .iter() + .all(|tools| *tools == &expected_public_tools) + ); + assert!(first.iter().all(|event| { + event["response"]["tools"].as_array().is_none_or(|tools| { + tools + .iter() + .all(|tool| !(tool["type"] == "function" && tool["name"] == "tool_search")) + }) + })); + assert_eq!( + first + .iter() + .map(|event| event["sequence_number"].as_u64()) + .collect::>(), + (0..u64::try_from(first.len()).unwrap()).map(Some).collect::>() + ); + assert!(first.iter().all(|event| { + !matches!( + event["type"].as_str(), + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done" | "error") + ) + })); + let lifecycle = first + .iter() + .filter(|event| { + matches!( + event["type"].as_str(), + Some("response.output_item.added" | "response.output_item.done") + ) + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["item"]["type"], "tool_search_call"); + assert_eq!(lifecycle[1]["item"]["type"], "tool_search_call"); + assert_eq!(lifecycle[0]["item"]["id"], lifecycle[1]["item"]["id"]); + assert_eq!(lifecycle[0]["output_index"], lifecycle[1]["output_index"]); + let first_terminal = first.last().expect("first terminal"); + assert_eq!(first_terminal["response"]["output"][0], lifecycle[1]["item"]); + first_terminal["response"]["id"].as_str().unwrap().to_owned() +} + +async fn assert_ws_search_persistence(requests: &[Value], pool: &DbPool) { + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["tools"].as_array().map(Vec::len), Some(1)); + assert_eq!(requests[0]["tools"][0]["name"], "tool_search"); + assert!( + requests[1]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_weather") + ); + assert!(requests[1]["input"].as_array().unwrap().iter().any(|item| { + item["type"] == "function_call" && item["name"] == "tool_search" && item["call_id"] == "call_search" + })); + assert!( + requests[2]["input"] + .as_array() + .unwrap() + .iter() + .any(|item| { item["type"] == "function_call_output" && item["call_id"] == "call_weather" }) + ); + let stored_items = sqlx::query_scalar::<_, String>("SELECT data FROM items ORDER BY created_at, seq") + .fetch_all(pool) + .await + .expect("stored public items"); + assert!(stored_items.iter().any(|item| item.contains("tool_search_call"))); + assert!(stored_items.iter().any(|item| item.contains("tool_search_output"))); + assert!( + stored_items + .iter() + .all(|item| !item.contains("\"name\":\"tool_search\"")) + ); +} + +#[tokio::test] +async fn test_websocket_tool_search_streaming_continuation_is_public_and_persisted() { + let mock = MockResponsesServer::start(vec![ + sse_tool_search_call_response(), + sse_weather_function_call_response(), + sse_response("resp_final", "msg_final", "PARIS_WEATHER_OK"), + ]) + .await; + let fixture = storage_backed_state(&mock.url).await; + let (gateway_url, _gateway) = spawn_gateway(fixture.state.clone()).await; + let mut ws = connect_responses_ws(&gateway_url).await; + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "input": "find weather", + "tools": [tool_search_declaration(), deferred_weather_function()], + "parallel_tool_calls": false, + "store": true, + "stream": true + }), + ) + .await; + let first = recv_until_completed(&mut ws).await; + let first_response_id = assert_public_ws_search_lifecycle(&first); + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "previous_response_id": first_response_id, + "input": [{ + "type": "tool_search_output", + "call_id": "call_search", + "tools": [deferred_weather_function()] + }], + "store": true, + "stream": true + }), + ) + .await; + let second = recv_until_completed(&mut ws).await; + let second_terminal = second.last().expect("second terminal"); + assert_eq!(second_terminal["response"]["output"][0]["type"], "function_call"); + assert_eq!(second_terminal["response"]["output"][0]["name"], "get_weather"); + let second_response_id = second_terminal["response"]["id"].as_str().unwrap().to_owned(); + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "previous_response_id": second_response_id, + "input": [{ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }], + "store": true, + "stream": true + }), + ) + .await; + let third = recv_until_completed(&mut ws).await; + assert_eq!( + third.last().unwrap()["response"]["output"][0]["content"][0]["text"], + "PARIS_WEATHER_OK" + ); + + let requests = mock.request_bodies().await; + assert_ws_search_persistence(&requests, fixture.pool.as_ref()).await; +} + +#[tokio::test] +async fn test_websocket_generate_false_persists_valid_tool_search_state_for_reuse() { + let mock = MockResponsesServer::start(vec![sse_weather_function_call_response()]).await; + let fixture = storage_backed_state(&mock.url).await; + let (gateway_url, _gateway) = spawn_gateway(fixture.state.clone()).await; + let mut ws = connect_responses_ws(&gateway_url).await; + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "input": [ + { + "type": "tool_search_call", "id": "tsc_prewarm", "call_id": "call_prewarm", + "arguments": {"query": "weather"} + }, + { + "type": "tool_search_output", "call_id": "call_prewarm", + "tools": [deferred_weather_function()] + } + ], + "tools": [tool_search_declaration(), deferred_weather_function()], + "parallel_tool_calls": false, + "generate": false, + "store": false, + "stream": true + }), + ) + .await; + let prewarm = recv_until_completed(&mut ws).await; + assert!( + mock.request_bodies().await.is_empty(), + "generate:false must not run inference" + ); + let response_id = prewarm.last().unwrap()["response"]["id"].as_str().unwrap().to_owned(); + + send_json( + &mut ws, + json!({ + "type": "response.create", + "model": "test-model", + "previous_response_id": response_id, + "input": "call the loaded weather tool", + "store": true, + "stream": true + }), + ) + .await; + let response = recv_until_completed(&mut ws).await; + assert_eq!(response.last().unwrap()["response"]["output"][0]["name"], "get_weather"); + + let requests = mock.request_bodies().await; + assert_eq!(requests.len(), 1); + assert!( + requests[0]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_weather") + ); + assert!( + requests[0]["input"] + .as_array() + .unwrap() + .iter() + .any(|item| item["type"] == "function_call_output") + ); +} + #[tokio::test] async fn test_websocket_first_turn_forwards_incremental_events_and_final_payload() { let mock = MockResponsesServer::start(vec![sse_response("resp_upstream_1", "msg_upstream_1", "HELLO")]).await; From 1e2c1bbec1afb51addc6fc0340286c684b075183 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Mon, 24 Aug 2026 05:36:59 +0000 Subject: [PATCH 03/11] Updates Signed-off-by: haoshan98 --- .../tests/tool_search_characterization_test.rs | 12 ++++++------ crates/agentic-server-core/tests/tool_search_test.rs | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/agentic-server-core/tests/tool_search_characterization_test.rs b/crates/agentic-server-core/tests/tool_search_characterization_test.rs index 3fd22fe5..efd829da 100644 --- a/crates/agentic-server-core/tests/tool_search_characterization_test.rs +++ b/crates/agentic-server-core/tests/tool_search_characterization_test.rs @@ -1064,21 +1064,21 @@ fn provider_projection(filename: &str) -> Projection { } #[cfg(unix)] -fn assert_recorder_cassette_mode(path: &Path, filename: &str) { +fn assert_cassette_is_not_executable(path: &Path, filename: &str) { use std::os::unix::fs::PermissionsExt as _; assert_eq!( fs::metadata(path) .expect("characterization cassette metadata") .permissions() .mode() - & 0o777, - 0o664, - "recorder-generated cassette mode drift in {filename}" + & 0o111, + 0, + "checked-in cassette must not be executable: {filename}" ); } #[cfg(not(unix))] -fn assert_recorder_cassette_mode(_path: &Path, _filename: &str) {} +fn assert_cassette_is_not_executable(_path: &Path, _filename: &str) {} fn assert_public_request_projection( directory: &Path, @@ -1207,7 +1207,7 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow path.is_file(), "required provider parity cassette is missing: {filename}" ); - assert_recorder_cassette_mode(&path, filename); + assert_cassette_is_not_executable(&path, filename); let raw_text = fs::read_to_string(&path).expect("characterization cassette should be readable"); let raw_document = serde_yaml::from_str::(&raw_text).expect("characterization YAML should be valid"); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); diff --git a/crates/agentic-server-core/tests/tool_search_test.rs b/crates/agentic-server-core/tests/tool_search_test.rs index 6b21715d..144b6a2f 100644 --- a/crates/agentic-server-core/tests/tool_search_test.rs +++ b/crates/agentic-server-core/tests/tool_search_test.rs @@ -1760,9 +1760,9 @@ fn provider_parity_matrix_is_exact_and_gateway_has_no_private_search_leaks() { { use std::os::unix::fs::PermissionsExt as _; assert_eq!( - fs::metadata(&path).expect("cassette metadata").permissions().mode() & 0o777, - 0o664, - "generated cassette mode drift in {filename}" + fs::metadata(&path).expect("cassette metadata").permissions().mode() & 0o111, + 0, + "checked-in cassette must not be executable: {filename}" ); } if gateway { From bc3c4e74b6584b01394a727c869162ee71aa5944 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Tue, 25 Aug 2026 10:34:30 +0000 Subject: [PATCH 04/11] Fix Signed-off-by: haoshan98 --- crates/agentic-server/src/handler/websocket/responses.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index 4f183d0f..eef0db6b 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -51,7 +51,7 @@ fn upgrade_responses_ws( .max_frame_size(MAX_BODY_SIZE) .on_upgrade(move |socket| async move { let _websocket_guard = websocket_guard; - responses_ws_loop(socket, state, headers, principal).await; + Box::pin(responses_ws_loop(socket, state, headers, principal)).await; }) } From c0dd0a44b2114bb9663e930bb10621c6d9399456 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Thu, 27 Aug 2026 13:12:31 +0000 Subject: [PATCH 05/11] Updates Signed-off-by: haoshan98 --- ARCHITECTURE.md | 2 +- .../src/events/normalize.rs | 3 + .../agentic-server-core/src/events/types.rs | 7 + .../src/executor/accumulator.rs | 391 +- .../src/executor/compaction.rs | 31 +- .../src/executor/engine.rs | 210 +- .../agentic-server-core/src/executor/error.rs | 20 +- .../src/executor/function_sse.rs | 559 +- .../src/executor/gateway.rs | 6 +- .../agentic-server-core/src/executor/mod.rs | 2 +- .../src/executor/modes/conversation.rs | 44 +- .../src/executor/modes/response.rs | 19 +- .../src/executor/persist.rs | 53 +- .../src/executor/prepare.rs | 73 +- .../src/executor/rehydrate.rs | 86 +- .../src/executor/request.rs | 186 +- .../src/executor/upstream.rs | 56 +- crates/agentic-server-core/src/lib.rs | 2 +- .../src/storage/conversation.rs | 42 +- .../src/storage/models/item.rs | 13 +- .../src/storage/models/response.rs | 26 +- .../src/storage/types/conversation.rs | 6 - .../src/storage/types/item.rs | 45 +- .../src/storage/types/response.rs | 27 +- crates/agentic-server-core/src/tool/codex.rs | 206 +- .../agentic-server-core/src/tool/executors.rs | 77 +- .../agentic-server-core/src/tool/handler.rs | 9 +- .../src/tool/mcp/handler.rs | 42 +- .../agentic-server-core/src/tool/mcp/pool.rs | 35 +- crates/agentic-server-core/src/tool/mod.rs | 9 +- crates/agentic-server-core/src/tool/names.rs | 153 - .../agentic-server-core/src/tool/normalize.rs | 32 +- .../agentic-server-core/src/tool/registry.rs | 630 +- .../src/tool/{search.rs => tool_search.rs} | 883 ++- .../agentic-server-core/src/types/io/input.rs | 54 +- .../src/types/io/output.rs | 158 +- .../src/types/request_response.rs | 380 +- .../src/types/tools/params.rs | 72 +- .../tests/cassettes/README.md | 29 +- .../tests/cassettes/record_cassette.py | 594 +- .../cassettes/record_tool_search_cassettes.sh | 292 +- .../cassettes/test_record_tool_search.py | 776 +-- .../tool_search/function_outputs.json | 4 +- .../gateway_tool_choice_sequence.json | 13 + .../openai_tool_choice_sequence.json | 9 + .../cassettes/tool_search/openai_tools.json | 104 +- .../tests/cassettes/tool_search/prompts.txt | 7 +- .../cassettes/tool_search/returned_tools.json | 26 +- ...Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml | 697 ++- ...lm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml | 3675 ++++++++++-- ...Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml | 836 ++- ...ay-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml | 2324 ++++++- ...ay-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml | 5317 +++++++++++++++-- ...openai-reference-gpt-5.6-nonstreaming.yaml | 443 +- ...ch-openai-reference-gpt-5.6-streaming.yaml | 418 +- .../tool_search/vllm_initial_tools.json | 4 +- .../vllm_tool_choice_sequence.json | 15 + .../tool_search/vllm_tools_after_search.json | 22 +- .../tests/event_normalizer_test.rs | 26 +- .../stateful_conversation_integration.rs | 6 +- .../tests/stateful_responses_integration.rs | 6 - .../tests/storage_integration.rs | 93 +- .../tests/tool_normalization_test.rs | 3 - .../tool_search_characterization_test.rs | 645 +- .../tests/tool_search_state_test.rs | 633 +- .../tests/tool_search_test.rs | 512 +- .../src/handler/websocket/responses.rs | 4 +- crates/agentic-server/tests/responses_test.rs | 69 - .../tests/responses_websocket_test.rs | 3 - 69 files changed, 15305 insertions(+), 6949 deletions(-) delete mode 100644 crates/agentic-server-core/src/tool/names.rs rename crates/agentic-server-core/src/tool/{search.rs => tool_search.rs} (65%) create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/gateway_tool_choice_sequence.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/openai_tool_choice_sequence.json create mode 100644 crates/agentic-server-core/tests/cassettes/tool_search/vllm_tool_choice_sequence.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64b391dd..b1c736e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -198,7 +198,7 @@ access happen — those live in `tool/`, `executor/`, and `storage/` respectivel (the normalized `FunctionTool` and `ToolChoice`, distinct from tool *declarations*), `usage.rs` (token accounting structs). - **`types/tools/params.rs`** — the tool **declaration** shapes a client sends: - `ResponsesTool` (tagged enum: `Function`, `Mcp`, `WebSearch`, `FileSearch`, + `ResponsesTool` (tagged enum: `Function`, `ToolSearch`, `Mcp`, `WebSearch`, `FileSearch`, `CodeInterpreter`, `Namespace`, `Custom`, `Unknown`) and each variant's param struct. This is a good concrete example of the module boundary: `ResponsesTool` is *defined* here as a pure shape, but its behavior — `validate()` and `to_function_tools()` — is diff --git a/crates/agentic-server-core/src/events/normalize.rs b/crates/agentic-server-core/src/events/normalize.rs index c83da97d..d46818ed 100644 --- a/crates/agentic-server-core/src/events/normalize.rs +++ b/crates/agentic-server-core/src/events/normalize.rs @@ -108,6 +108,9 @@ fn extract_output_item_added(json: &Value) -> EventPayload { name: json_str_opt(item, "name"), namespace: json_str_opt(item, "namespace"), call_id: json_str_opt(item, "call_id"), + execution: item.get("execution").cloned().and_then(deserialize_from_value_opt), + status: json_str_opt(item, "status"), + arguments: item.get("arguments").and_then(Value::as_object).cloned(), } } diff --git a/crates/agentic-server-core/src/events/types.rs b/crates/agentic-server-core/src/events/types.rs index ae012736..da938ab5 100644 --- a/crates/agentic-server-core/src/events/types.rs +++ b/crates/agentic-server-core/src/events/types.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::types::io::ResponseUsage; +use crate::types::tools::ToolSearchExecution; /// The type of an output item received during streaming. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -12,6 +13,7 @@ pub enum SSEItemType { WebSearchCall, McpCall, McpListTools, + ToolSearchCall, Compaction, Message, } @@ -26,6 +28,7 @@ impl SSEItemType { Self::WebSearchCall => "web_search_call", Self::McpCall => "mcp_call", Self::McpListTools => "mcp_list_tools", + Self::ToolSearchCall => "tool_search_call", Self::Compaction => "compaction", Self::Message => "message", } @@ -41,6 +44,7 @@ impl From<&str> for SSEItemType { "web_search_call" => Self::WebSearchCall, "mcp_call" => Self::McpCall, "mcp_list_tools" => Self::McpListTools, + "tool_search_call" => Self::ToolSearchCall, "compaction" => Self::Compaction, _ => Self::Message, } @@ -252,6 +256,9 @@ pub enum EventPayload { name: Option, namespace: Option, call_id: Option, + execution: Option, + status: Option, + arguments: Option>, }, /// `response.output_item.done` diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index c68a6994..011216a2 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -7,6 +7,7 @@ //! runs on a blocking thread while the async task continues reading from the //! network — keeping the tokio executor thread free between chunk arrivals. +use std::collections::{HashMap, HashSet}; use std::pin::Pin; use std::sync::mpsc; @@ -17,14 +18,16 @@ 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::tool::ToolType; use crate::types::event::{MessageStatus, ResponseStatus}; use crate::types::io::output::McpListTools; use crate::types::io::{ ApplyDone, CompactionItem, CustomToolCall, FunctionToolCall, OutputItem, OutputMessage, OutputTextContent, - ReasoningOutput, ReasoningTextContent, ResponseUsage, + ReasoningOutput, ReasoningTextContent, ResponseUsage, ToolSearchCall, }; use crate::types::io::{McpCall, WebSearchCall}; use crate::types::request_response::{IncompleteDetails, ResponsePayload}; +use crate::types::tools::ToolSearchStatus; use crate::utils::common::{deserialize_from_str, deserialize_from_value_opt}; use crate::utils::uuid7_str; @@ -38,6 +41,7 @@ enum InFlight { WebSearchCall { item: Option }, McpCall { item: McpCall }, McpListTools { item: McpListTools }, + ToolSearchCall(ToolSearchCall), Compaction { item: CompactionItem }, } @@ -51,45 +55,68 @@ impl std::fmt::Debug for InFlight { Self::WebSearchCall { .. } => write!(f, "InFlight::WebSearchCall {{ .. }}"), Self::McpCall { .. } => write!(f, "InFlight::McpCall {{ .. }}"), Self::McpListTools { .. } => write!(f, "InFlight::McpListTools {{ .. }}"), + Self::ToolSearchCall(..) => write!(f, "InFlight::ToolSearchCall(..)"), Self::Compaction { .. } => write!(f, "InFlight::Compaction {{ .. }}"), } } } impl InFlight { - fn finalize(self) -> Option { + fn finalize( + self, + tool_types: &HashMap, + discard_incomplete_tool_search: bool, + ) -> ExecutorResult> { match self { Self::Reasoning { mut item, text } => { if !text.is_empty() { item.content.push(ReasoningTextContent::new(text)); } - Some(OutputItem::Reasoning(item)) + Ok(Some(OutputItem::Reasoning(item))) } Self::FunctionCall { mut item, arguments } => { + if tool_types.get(&item.name) == Some(&ToolType::ToolSearch) + && item.status != MessageStatus::Completed + && discard_incomplete_tool_search + { + return Ok(None); + } if !arguments.is_empty() && item.arguments.is_empty() { item.arguments = arguments; } item.status = MessageStatus::Completed; - Some(OutputItem::FunctionCall(item)) + if tool_types.get(&item.name) == Some(&ToolType::ToolSearch) { + ToolSearchCall::try_from(&item) + .map(OutputItem::ToolSearchCall) + .map(Some) + .map_err(ExecutorError::Tool) + } else { + Ok(Some(OutputItem::FunctionCall(item))) + } } Self::Message { mut item, text } => { if !text.is_empty() { item.content.push(OutputTextContent::new(text)); } item.status = MessageStatus::Completed; - Some(OutputItem::Message(item)) + Ok(Some(OutputItem::Message(item))) } Self::CustomToolCall { mut item, input } => { if item.input.is_empty() { item.input = input; } item.status = Some(MessageStatus::Completed); - Some(OutputItem::CustomToolCall(item)) + Ok(Some(OutputItem::CustomToolCall(item))) + } + Self::WebSearchCall { item } => Ok(item.map(OutputItem::WebSearchCall)), + Self::McpCall { item } => Ok(Some(OutputItem::McpCall(item))), + Self::McpListTools { item } => Ok(Some(OutputItem::McpListTools(item))), + Self::ToolSearchCall(item) if item.status == ToolSearchStatus::Completed => { + Ok(Some(OutputItem::ToolSearchCall(item))) } - Self::WebSearchCall { item } => item.map(OutputItem::WebSearchCall), - Self::McpCall { item } => Some(OutputItem::McpCall(item)), - Self::McpListTools { item } => Some(OutputItem::McpListTools(item)), - Self::Compaction { item } => Some(OutputItem::Compaction(item)), + Self::ToolSearchCall(_) if discard_incomplete_tool_search => Ok(None), + Self::ToolSearchCall(_) => Err(crate::tool::tool_search::invalid_upstream_search_call().into()), + Self::Compaction { item } => Ok(Some(OutputItem::Compaction(item))), } } } @@ -131,6 +158,9 @@ pub struct ResponseAccumulator { in_flight: IndexMap, /// Completed streaming items waiting to be emitted in `output_index` order. completed: Vec<(u32, OutputItem)>, + /// Request-scoped model-visible tool classification. + tool_types: HashMap, + processing_error: Option, } impl ResponseAccumulator { @@ -147,21 +177,53 @@ impl ResponseAccumulator { error: None, in_flight: IndexMap::new(), completed: Vec::new(), + tool_types: HashMap::new(), + processing_error: None, } } + pub(super) fn with_tool_types( + mut self, + tool_types: HashMap, + withheld_function_names: &HashSet, + ) -> ExecutorResult { + let discard_incomplete_tool_search = matches!(self.status, ResponseStatus::Error | ResponseStatus::Incomplete); + let output = std::mem::take(&mut self.output); + self.output = output + .into_iter() + .map(|item| { + if matches!(&item, OutputItem::FunctionCall(call) if withheld_function_names.contains(&call.name)) { + return Err(crate::tool::tool_search::invalid_upstream_withheld_function_call().into()); + } + if discard_incomplete_tool_search + && matches!(&item, OutputItem::FunctionCall(call) + if tool_types.get(&call.name) == Some(&ToolType::ToolSearch) + && call.status != MessageStatus::Completed) + { + return Ok(None); + } + if discard_incomplete_tool_search + && matches!(&item, OutputItem::ToolSearchCall(call) + if call.status != ToolSearchStatus::Completed) + { + return Ok(None); + } + normalize_output_item(item, &tool_types).map(Some) + }) + .collect::>>()? + .into_iter() + .flatten() + .collect(); + self.tool_types = tool_types; + Ok(self) + } + /// Parses a non-streaming JSON response body. /// /// # Errors /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { - let json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; - Self::from_value(json, conversation_id) - } - - /// Rehydrate a parsed non-streaming response without parsing the body a - /// second time after raw protocol validation. - pub(super) fn from_value(mut json: serde_json::Value, conversation_id: Option<&str>) -> ExecutorResult { + let mut json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; let response_id = json["id"] .as_str() .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))? @@ -169,9 +231,9 @@ impl ResponseAccumulator { let output = deserialize_from_value_opt::>(json["output"].take()) .map(|items| { - let mut out = Vec::with_capacity(items.len()); - out.extend(items.into_iter().filter_map(deserialize_from_value_opt::)); - out + let mut output = Vec::with_capacity(items.len()); + output.extend(items.into_iter().filter_map(deserialize_from_value_opt::)); + output }) .unwrap_or_default(); @@ -193,6 +255,8 @@ impl ResponseAccumulator { error, in_flight: IndexMap::new(), completed: Vec::new(), + tool_types: HashMap::new(), + processing_error: None, }) } @@ -234,17 +298,20 @@ impl ResponseAccumulator { // Properly async join — does not block the tokio executor thread. worker_handle .await - .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into())) + .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into()))? } /// Worker function that processes SSE lines from the channel (runs on blocking thread). - fn process_stream_chunks(rx: mpsc::Receiver, conversation_id: Option) -> Self { + fn process_stream_chunks(rx: mpsc::Receiver, conversation_id: Option) -> ExecutorResult { let mut acc = Self::new(uuid7_str("resp_"), conversation_id); for line in rx { let _ = acc.process_sse_line(&line); } acc.finish_stream(); - acc + if let Some(error) = acc.take_processing_error() { + return Err(error); + } + Ok(acc) } /// Processes pre-collected raw SSE lines synchronously. @@ -264,11 +331,14 @@ impl ResponseAccumulator { /// Finalizes all streaming items in upstream `output_index` order. pub(crate) fn finalize_all(&mut self) { - self.completed.extend( - self.in_flight - .drain(..) - .filter_map(|(_, entry)| entry.item.finalize().map(|item| (entry.output_index, item))), - ); + let discard_incomplete_tool_search = matches!(self.status, ResponseStatus::Error | ResponseStatus::Incomplete); + for (_, entry) in self.in_flight.drain(..) { + match entry.item.finalize(&self.tool_types, discard_incomplete_tool_search) { + Ok(Some(item)) => self.completed.push((entry.output_index, item)), + Err(error) if self.processing_error.is_none() => self.processing_error = Some(error), + Ok(None) | Err(_) => {} + } + } self.completed.sort_by_key(|(output_index, _)| *output_index); self.output .extend(self.completed.drain(..).map(|(_, output_item)| output_item)); @@ -276,8 +346,7 @@ impl ResponseAccumulator { pub(crate) fn process_sse_line(&mut self, line: &str) -> Option { let frame = normalize_sse_line(line)?; - self.capture_terminal_details_if_needed(&frame); - self.process_event(&frame); + self.process_normalized_frame(&frame); Some(frame) } @@ -292,10 +361,19 @@ impl ResponseAccumulator { 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.validate_before_accumulation(&frame, call)?; - self.capture_terminal_details_if_needed(&frame); - self.process_event(&frame); + self.process_normalized_frame(&frame); + if let Some(error) = self.take_processing_error() { + return Err(error); + } let call = call_key.and_then(|(item_id, output_index)| self.accumulated_function_call(item_id, output_index)); - translator.translate(frame, call).map(Some) + let tool_search_call = + call_key.and_then(|(item_id, output_index)| self.accumulated_tool_search_call(item_id, output_index)); + translator.translate(frame, call, tool_search_call).map(Some) + } + + fn process_normalized_frame(&mut self, frame: &EventFrame) { + self.capture_terminal_details_if_needed(frame); + self.process_event(frame); } fn accumulated_function_call(&self, item_id: &str, output_index: u32) -> Option> { @@ -308,14 +386,14 @@ impl ResponseAccumulator { entry.output_index == output_index && matches!(entry.item, InFlight::FunctionCall { .. }) }) }); - if let Some(InFlightEntry { - output_index, - item: InFlight::FunctionCall { item, arguments }, - }) = entry - { + if let Some(entry) = entry { + let (item, arguments) = match &entry.item { + InFlight::FunctionCall { item, arguments } => (item, arguments.as_str()), + _ => return None, + }; return Some(AccumulatedFunctionCall { item, - output_index: *output_index, + output_index: entry.output_index, arguments, }); } @@ -332,6 +410,13 @@ impl ResponseAccumulator { }) } + fn accumulated_tool_search_call(&self, item_id: &str, output_index: u32) -> Option<&ToolSearchCall> { + self.in_flight.get(item_id).and_then(|entry| match &entry.item { + InFlight::ToolSearchCall(item) if entry.output_index == output_index => Some(item), + _ => None, + }) + } + fn capture_terminal_details(&mut self, frame: &EventFrame) { let Some(response) = frame.wire.rest.get("response") else { return; @@ -374,7 +459,11 @@ impl ResponseAccumulator { self.start_output_item(payload); } (SSEEventType::OutputItemDone, payload @ EventPayload::OutputItemDone { .. }) => { - self.complete_call_item(payload); + if let Err(error) = self.complete_call_item(payload) + && self.processing_error.is_none() + { + self.processing_error = Some(error); + } } (SSEEventType::ReasoningTextDelta, EventPayload::ReasoningDelta { delta, item_id }) => { if let Some(InFlight::Reasoning { text, .. }) = @@ -499,6 +588,15 @@ impl ResponseAccumulator { SSEItemType::McpListTools => McpListTools::try_from(payload) .ok() .map(|item| InFlight::McpListTools { item }), + SSEItemType::ToolSearchCall => match ToolSearchCall::try_from(payload) { + Ok(item) => Some(InFlight::ToolSearchCall(item)), + Err(error) => { + if self.processing_error.is_none() { + self.processing_error = Some(error.into()); + } + None + } + }, }; if let Some(item) = item { let needs_internal_key = matches!(&item, InFlight::FunctionCall { .. }) @@ -523,12 +621,12 @@ impl ResponseAccumulator { } fn finish_response(&mut self, status: ResponseStatus, usage: Option) { - self.finalize_all(); self.status = status; + self.finalize_all(); self.usage = usage; } - fn complete_call_item(&mut self, payload: &EventPayload) { + fn complete_call_item(&mut self, payload: &EventPayload) -> ExecutorResult<()> { let EventPayload::OutputItemDone { item_id, item_type, @@ -537,17 +635,46 @@ impl ResponseAccumulator { .. } = payload else { - return; + return Ok(()); }; 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::McpListTools { item }, _) => item.apply_done(payload, &mut String::new()), - (InFlight::Compaction { item }, _) => item.apply_done(payload, &mut String::new()), + let replacement = match (&mut entry.item, done_item) { + (InFlight::FunctionCall { item, arguments }, _) => { + let is_tool_search = self.tool_types.get(&item.name) == Some(&ToolType::ToolSearch) + || raw_item + .get("name") + .and_then(serde_json::Value::as_str) + .is_some_and(|name| self.tool_types.get(name) == Some(&ToolType::ToolSearch)); + item.apply_done(payload, arguments); + if is_tool_search { + let public = ToolSearchCall::try_from(&*item).map_err(ExecutorError::Tool)?; + Some(InFlight::ToolSearchCall(public)) + } else { + None + } + } + (InFlight::CustomToolCall { item, input }, _) => { + item.apply_done(payload, input); + None + } + (InFlight::McpCall { item }, _) => { + item.apply_done(payload, &mut String::new()); + None + } + (InFlight::McpListTools { item }, _) => { + item.apply_done(payload, &mut String::new()); + None + } + (InFlight::ToolSearchCall(item), _) => { + item.apply_done(payload, &mut String::new()); + None + } + (InFlight::Compaction { item }, _) => { + item.apply_done(payload, &mut String::new()); + None + } (InFlight::WebSearchCall { item }, Some(OutputItem::WebSearchCall(mut call))) => { if call.id.is_empty() { call.id = in_flight_key @@ -556,30 +683,45 @@ impl ResponseAccumulator { .map_or_else(|| uuid7_str("ws_"), str::to_owned); } *item = Some(call); + None } - _ => {} + _ => None, + }; + if let Some(replacement) = replacement { + entry.item = replacement; } - return; + return Ok(()); } - if let Some( - mut output_item @ (OutputItem::FunctionCall(_) - | OutputItem::CustomToolCall(_) - | OutputItem::WebSearchCall(_) - | OutputItem::McpCall(_) - | OutputItem::McpListTools(_) - | OutputItem::Compaction(_)), - ) = done_item + self.complete_untracked_call_item(done_item, *output_index) + } + + fn complete_untracked_call_item(&mut self, done_item: Option, output_index: u32) -> ExecutorResult<()> { + let Some(mut output_item) = done_item + .map(|item| normalize_output_item(item, &self.tool_types)) + .transpose()? + else { + return Ok(()); + }; + if !matches!( + output_item, + OutputItem::FunctionCall(_) + | OutputItem::ToolSearchCall(_) + | OutputItem::CustomToolCall(_) + | OutputItem::WebSearchCall(_) + | OutputItem::McpCall(_) + | OutputItem::McpListTools(_) + | OutputItem::Compaction(_) + ) { + return Ok(()); + } + if let OutputItem::WebSearchCall(call) = &mut output_item + && call.id.is_empty() { - let OutputItem::WebSearchCall(call) = &mut output_item else { - self.completed.push((*output_index, output_item)); - return; - }; - if call.id.is_empty() { - call.id = uuid7_str("ws_"); - } - self.completed.push((*output_index, output_item)); + call.id = uuid7_str("ws_"); } + self.completed.push((output_index, output_item)); + Ok(()) } fn in_flight_call_key(&self, item_id: &str, item_type: SSEItemType, output_index: u32) -> Option { @@ -603,6 +745,10 @@ impl ResponseAccumulator { }); } + pub(super) fn take_processing_error(&mut self) -> Option { + self.processing_error.take() + } + /// Finalizes the accumulator into a `ResponsePayload`. /// /// The caller supplies fields that come from the original request, not from @@ -627,6 +773,8 @@ impl ResponseAccumulator { previous_response_id: previous_response_id.map(str::to_string), conversation_id: self.conversation_id, instructions: instructions.map(str::to_string), + tools: None, + tool_choice: None, } } } @@ -639,10 +787,25 @@ fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool | (InFlight::WebSearchCall { .. }, SSEItemType::WebSearchCall) | (InFlight::McpCall { .. }, SSEItemType::McpCall) | (InFlight::McpListTools { .. }, SSEItemType::McpListTools) + | (InFlight::ToolSearchCall(_), SSEItemType::ToolSearchCall) | (InFlight::Compaction { .. }, SSEItemType::Compaction) ) } +fn normalize_output_item(item: OutputItem, tool_types: &HashMap) -> ExecutorResult { + match item { + OutputItem::FunctionCall(call) if tool_types.get(&call.name) == Some(&ToolType::ToolSearch) => { + ToolSearchCall::try_from(&call) + .map(OutputItem::ToolSearchCall) + .map_err(ExecutorError::Tool) + } + OutputItem::ToolSearchCall(call) if call.status != ToolSearchStatus::Completed => { + Err(crate::tool::tool_search::invalid_upstream_search_call().into()) + } + item => Ok(item), + } +} + fn function_event_key(payload: &EventPayload) -> Option<(&str, u32)> { match payload { EventPayload::OutputItemAdded { @@ -801,6 +964,9 @@ mod tests { name: None, namespace: None, call_id: None, + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1341,6 +1507,9 @@ mod tests { name: Some("get_weather".into()), namespace: Some("mcp__weather".into()), call_id: Some("call_abc".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1416,6 +1585,9 @@ mod tests { name: Some("search".into()), namespace: None, call_id: Some("call_1".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1465,6 +1637,9 @@ mod tests { name: Some("get_weather".into()), namespace: None, call_id: Some("call_1".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1489,6 +1664,9 @@ mod tests { name: Some("get_time".into()), namespace: None, call_id: Some("call_2".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1532,6 +1710,9 @@ mod tests { name: None, namespace: None, call_id: None, + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1555,6 +1736,9 @@ mod tests { name: Some("lookup".into()), namespace: None, call_id: Some("call_x".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1598,6 +1782,9 @@ mod tests { name: Some("old_name".into()), namespace: None, call_id: Some("old_call".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1693,6 +1880,9 @@ mod tests { name: Some("tool".into()), namespace: None, call_id: Some("c1".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1750,6 +1940,9 @@ mod tests { name: Some("partial".into()), namespace: None, call_id: Some("c1".into()), + execution: None, + status: None, + arguments: None, }, wire: WireEvent::new("test"), }); @@ -1809,6 +2002,70 @@ mod tests { assert_eq!(acc.usage.unwrap().total_tokens, 15); } + #[test] + fn test_native_tool_search_call_accumulates_as_first_class_item() { + let lines = vec![ + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}}}"#.to_owned(), + r#"data: {"type":"response.output_item.done","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"completed","call_id":"call_search","execution":"client","arguments":{"query":"weather"}}}"#.to_owned(), + r#"data: {"type":"response.completed","response":{"id":"resp_native","status":"completed","usage":null}}"#.to_owned(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert!(acc.processing_error.is_none()); + let [OutputItem::ToolSearchCall(call)] = acc.output.as_slice() else { + panic!("expected one native tool-search call"); + }; + assert_eq!(call.id, "tsc_native"); + assert_eq!(call.call_id, "call_search"); + assert_eq!(call.status, ToolSearchStatus::Completed); + assert_eq!( + call.arguments, + serde_json::json!({"query": "weather"}).as_object().unwrap().clone() + ); + } + + #[test] + fn failed_response_discards_unfinished_tool_search_items() { + let added_items = [ + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_search","type":"function_call","status":"in_progress","call_id":"call_search","name":"tool_search","arguments":""}}"#, + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}}}"#, + ]; + for added in added_items { + let mut acc = ResponseAccumulator::new("resp_failed".to_owned(), None) + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .unwrap(); + acc.process_sse_line(added); + acc.process_sse_line( + r#"data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","usage":null}}"#, + ); + + assert_eq!(acc.status, ResponseStatus::Error); + assert!(acc.output.is_empty()); + assert!(acc.processing_error.is_none()); + } + } + + #[test] + fn completed_response_rejects_unfinished_synthetic_tool_search() { + let mut acc = ResponseAccumulator::new("resp_invalid".to_owned(), None) + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .unwrap(); + acc.process_sse_line( + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_search","type":"function_call","status":"in_progress","call_id":"call_search","name":"tool_search","arguments":""}}"#, + ); + acc.process_sse_line( + r#"data: {"type":"response.completed","response":{"id":"resp_invalid","status":"completed","usage":null}}"#, + ); + + assert!(acc.processing_error.is_some()); + } + #[test] fn test_custom_tool_call_accumulates_freeform_input() { let lines = vec![ diff --git a/crates/agentic-server-core/src/executor/compaction.rs b/crates/agentic-server-core/src/executor/compaction.rs index dd9caeb4..8fb27698 100644 --- a/crates/agentic-server-core/src/executor/compaction.rs +++ b/crates/agentic-server-core/src/executor/compaction.rs @@ -1,4 +1,5 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::persist::persist_prepared_turn; use crate::executor::prepare::prepare_tool_search; use crate::executor::rehydrate::rehydrate_conversation; use crate::executor::request::{ExecutionContext, RequestContext}; @@ -201,9 +202,6 @@ pub(crate) async fn compact_items( let ctx = RequestContext { original_request, enriched_request, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: uuid7_str("resp_"), conversation_id: None, @@ -242,7 +240,7 @@ pub(crate) async fn maybe_compact_context( let Some(threshold) = threshold else { return Ok(None); }; - let estimated_tokens = estimate_input_tokens(&ctx.inference_request().input); + let estimated_tokens = estimate_input_tokens(&ctx.enriched_request.input); if estimated_tokens <= threshold { return Ok(None); } @@ -254,15 +252,8 @@ pub(crate) async fn maybe_compact_context( ); let model = ctx.enriched_request.model.clone(); let instructions = ctx.enriched_request.instructions.clone(); - let has_private_request = ctx.tool_search_private_request.is_some(); - let input = std::mem::replace( - &mut ctx.inference_request_mut().input, - ResponsesInput::Items(Vec::new()), - ); + let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new())); let (compacted, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; - if has_private_request { - ctx.inference_request_mut().input = ResponsesInput::Items(compacted.clone()); - } ctx.enriched_request.input = ResponsesInput::Items(compacted.clone()); ctx.new_input_items = compacted; Ok(Some(usage)) @@ -291,19 +282,19 @@ pub async fn compact_response( request.instructions, ); payload.previous_response_id = request.previous_response_id; - let mut ctx = rehydrate_conversation(payload, exec_ctx).await?; - prepare_tool_search(&mut ctx)?; - let model = ctx.enriched_request.model.clone(); - let instructions = ctx.enriched_request.instructions.clone(); + let ctx = rehydrate_conversation(payload, exec_ctx).await?; + let mut ctx = prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler).await?; + let model = ctx.request().enriched_request.model.clone(); + let instructions = ctx.request().enriched_request.instructions.clone(); let input = std::mem::replace( - &mut ctx.inference_request_mut().input, + &mut ctx.request_mut().enriched_request.input, ResponsesInput::Items(Vec::new()), ); let (output, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; - let response_id = ctx.response_id.clone(); - ctx.new_input_items.clone_from(&output); - match exec_ctx.resp_handler.execute_turn(ctx, Vec::new()).await { + let response_id = ctx.request().response_id.clone(); + ctx.request_mut().new_input_items.clone_from(&output); + match persist_prepared_turn(ctx, Vec::new(), &exec_ctx.conv_handler, &exec_ctx.resp_handler).await { Ok(()) | Err(ExecutorError::Storage(crate::StorageError::NotConfigured)) => {} Err(error) => return Err(error), } diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index b7261914..adcc010f 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -5,7 +5,6 @@ //! primary entry point; [`execute`] is a convenience shim for callers that don't //! need per-request configuration. -use std::collections::HashSet; use std::sync::Arc; use async_stream::stream; @@ -23,16 +22,16 @@ use super::gateway::{ }; use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk}; use crate::events::EventFrame; -use crate::executor::error::ExecutorResult; +use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::inference::DONE_MARKER; use crate::executor::persist::persist_if_needed; -use crate::executor::rehydrate::rehydrate_for_execution; -use crate::executor::request::{ExecutionContext, RequestContext}; +use crate::executor::prepare::prepare_tool_search; +use crate::executor::rehydrate::rehydrate_conversation; +use crate::executor::request::{ExecutionContext, PreparedTurn, RequestContext}; use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload}; -use crate::tool::{ToolRegistry, ToolSearchState, mcp}; +use crate::tool::{ToolRegistry, mcp}; use crate::types::io::{InputItem, OutputItem, ResponseUsage, ResponsesInput, ToolChoice}; use crate::types::request_response::{IncompleteDetails, RequestPayload, ResponsePayload}; -use crate::types::tools::ResponsesTool; use crate::utils::common::utcnow_str; pub use crate::executor::inference::BoxStream; @@ -98,13 +97,13 @@ impl Drop for AbortOnDrop { } async fn run_until_gateway_tools_complete( - ctx: RequestContext, + ctx: PreparedTurn, exec_ctx: &ExecutionContext, auth: Option<&str>, stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, -) -> ExecutorResult<(ResponsePayload, RequestContext)> { - if ctx.enriched_request.input.has_compaction_trigger() { +) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { + if ctx.request().enriched_request.input.has_compaction_trigger() { let (payload, ctx) = run_compaction_trigger(ctx, exec_ctx, auth).await?; if let Some((stream_accumulator, stream_sender)) = stream.as_mut() { emit_response_start_events(&payload, stream_accumulator, stream_sender)?; @@ -119,12 +118,12 @@ async fn run_until_gateway_tools_complete( } async fn run_gateway_tool_loop( - mut ctx: RequestContext, + mut ctx: PreparedTurn, exec_ctx: &ExecutionContext, auth: Option<&str>, stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, -) -> ExecutorResult<(ResponsePayload, RequestContext)> { +) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { let registry = build_request_tool_registry(&mut ctx, exec_ctx).await?; let mut combined_output: Vec = registry .mcp_list_tools_items() @@ -134,7 +133,7 @@ async fn run_gateway_tool_loop( let mut combined_usage = None; for round in 0..MAX_GATEWAY_TOOL_ROUNDS { - let compaction_usage = maybe_compact_context(&mut ctx, exec_ctx, auth).await?; + let compaction_usage = maybe_compact_context(ctx.request_mut(), exec_ctx, auth).await?; accumulate_usage(&mut combined_usage, compaction_usage); let output_offset = combined_output.len(); let (mut payload, deferred_stream_events): (ResponsePayload, Vec<_>) = if stream_upstream { @@ -151,20 +150,19 @@ async fn run_gateway_tool_loop( .await?; (stream_payload.payload, stream_payload.deferred_events) } else { - ( - fetch_blocking_payload(&ctx, exec_ctx, auth, ®istry).await?, - Vec::new(), - ) + let payload = fetch_blocking_payload(ctx.request(), exec_ctx, auth, ®istry).await?; + (payload, Vec::new()) }; - registry.restore_final_payload_output(&mut payload.output)?; + registry.restore_final_payload_output(&mut payload.output); accumulate_usage(&mut combined_usage, payload.usage.take()); let current_output = std::mem::take(&mut payload.output); - if matches!(payload.status.as_str(), "error" | "failed" | "incomplete") { + if matches!(payload.status.as_str(), "error" | "failed") { combined_output.extend(current_output); finalize_loop(&mut payload, combined_output, combined_usage, &ctx); return Ok((payload, ctx)); } - log_custom_tool_calls(¤t_output, &ctx.response_id); + let upstream_incomplete = payload.status == "incomplete"; + log_custom_tool_calls(¤t_output, &ctx.request().response_id); let has_client_owned = has_client_owned_calls(¤t_output, ®istry); let gateway_results = execute_and_emit_round_output_calls( ¤t_output, @@ -180,14 +178,24 @@ async fn run_gateway_tool_loop( let public_output = public_output_items(¤t_output, ®istry, &gateway_results); combined_output.extend(public_output); + if upstream_incomplete { + append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); + append_tool_outputs( + ctx.request_mut(), + gateway_results.into_iter().map(|result| result.input_item).collect(), + ); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx); + return Ok((payload, ctx)); + } + match classify_round(has_client_owned, &gateway_results, round, MAX_GATEWAY_TOOL_ROUNDS) { // Client-owned calls (plain function or Codex namespace tools) are // handed back to the caller. Gateway calls in the same turn are // still recorded so the returned conversation is complete. LoopDecision::RequiresClientAction => { - append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry); + append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); append_tool_outputs( - &mut ctx, + ctx.request_mut(), gateway_results.into_iter().map(|result| result.input_item).collect(), ); finalize_loop(&mut payload, combined_output, combined_usage, &ctx); @@ -204,9 +212,9 @@ async fn run_gateway_tool_loop( // The final round's gateway calls and outputs are recorded so a // continuation is not fed a dangling tool call. LoopDecision::Incomplete(reason) => { - append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry); + append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); append_tool_outputs( - &mut ctx, + ctx.request_mut(), gateway_results.into_iter().map(|result| result.input_item).collect(), ); finalize_loop(&mut payload, combined_output, combined_usage, &ctx); @@ -216,11 +224,11 @@ async fn run_gateway_tool_loop( } // Gateway tools ran and rounds remain; feed outputs back and loop. LoopDecision::Continue => { - ctx.inference_request_mut().tool_choice = Some(ToolChoice::Auto); - append_output_items_to_input(&mut ctx.inference_request_mut().input, ¤t_output); - append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry); + ctx.request_mut().enriched_request.tool_choice = Some(ToolChoice::Auto); + append_output_items_to_input(&mut ctx.request_mut().enriched_request.input, ¤t_output); + append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); append_tool_outputs( - &mut ctx, + ctx.request_mut(), gateway_results.into_iter().map(|result| result.input_item).collect(), ); } @@ -245,60 +253,41 @@ fn log_custom_tool_calls(output: &[OutputItem], response_id: &str) { } async fn build_request_tool_registry( - ctx: &mut RequestContext, + ctx: &mut PreparedTurn, exec_ctx: &ExecutionContext, ) -> ExecutorResult { - let loaded_mcp_server_labels = loaded_mcp_server_labels(ctx.tool_search_state.as_ref()); let mut executors = exec_ctx.gateway_executors.request_scoped(); - let mut registry = match ctx.inference_request_mut().tools.as_mut() { - Some(tools) => { - ToolRegistry::build_with_handlers_for_tool_search(tools, &mut executors, &loaded_mcp_server_labels).await? - } + let mut registry = match ctx.request_mut().enriched_request.tools.as_mut() { + Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?, None => ToolRegistry::default(), }; - if let Some(state) = &ctx.tool_search_state { - registry.classify_tool_search(state)?; - } + ctx.apply_tool_search_to_registry(&mut registry)?; Ok(registry) } -fn loaded_mcp_server_labels(state: Option<&ToolSearchState>) -> HashSet { - state - .filter(|state| state.is_active()) - .into_iter() - .flat_map(ToolSearchState::loaded_public_tools) - .filter_map(|tool| match tool { - ResponsesTool::Mcp(mcp) => Some(mcp.server_label.clone()), - _ => None, - }) - .collect() -} - /// Codex CLI remote-compaction V2: the client appends a `compaction_trigger` /// item to the input and expects the server to run its own summarization turn /// and stream back exactly one `compaction` output item plus `response.completed`. /// The trigger never reaches the upstream model; the summary inference is a /// normal blocking call against the same backend as standalone compaction. async fn run_compaction_trigger( - mut ctx: RequestContext, + mut ctx: PreparedTurn, exec_ctx: &ExecutionContext, auth: Option<&str>, -) -> ExecutorResult<(ResponsePayload, RequestContext)> { - let (model, instructions, input) = { - let inference_request = ctx.inference_request_mut(); - ( - inference_request.model.clone(), - inference_request.instructions.clone(), - std::mem::replace(&mut inference_request.input, ResponsesInput::Items(Vec::new())), - ) - }; +) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { + let model = ctx.request().enriched_request.model.clone(); + let instructions = ctx.request().enriched_request.instructions.clone(); + let input = std::mem::replace( + &mut ctx.request_mut().enriched_request.input, + ResponsesInput::Items(Vec::new()), + ); let (mut compacted, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; let Some(InputItem::Compaction(compaction)) = compacted.pop() else { unreachable!("compact_items always appends a compaction item"); }; - ctx.new_input_items = compacted; + ctx.request_mut().new_input_items = compacted; let mut payload = ResponsePayload { - id: ctx.response_id.clone(), + id: ctx.request().response_id.clone(), object: "response".to_owned(), created_at: utcnow_str(), model, @@ -307,11 +296,13 @@ async fn run_compaction_trigger( usage: Some(usage), incomplete_details: None, error: None, - previous_response_id: ctx.original_request.previous_response_id.clone(), - conversation_id: ctx.conversation_id.clone(), + previous_response_id: ctx.request().original_request.previous_response_id.clone(), + conversation_id: ctx.request().conversation_id.clone(), instructions, + tools: None, + tool_choice: None, }; - ctx.inject_ids(&mut payload); + ctx.request().inject_ids(&mut payload); Ok((payload, ctx)) } @@ -320,7 +311,7 @@ async fn execute_and_emit_round_output_calls( registry: &ToolRegistry, output_offset: usize, deferred_events: Vec, - ctx: &RequestContext, + ctx: &PreparedTurn, stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult> { match (deferred_events.is_empty(), stream) { @@ -346,7 +337,7 @@ async fn execute_and_emit_ordered_output_calls( registry: &ToolRegistry, output_offset: usize, deferred_events: Vec, - ctx: &RequestContext, + ctx: &PreparedTurn, stream_accumulator: &mut GatewayStreamAccumulator, stream_sender: &mpsc::UnboundedSender, ) -> ExecutorResult> { @@ -437,15 +428,19 @@ fn finalize_loop( payload: &mut ResponsePayload, combined_output: Vec, combined_usage: Option, - ctx: &RequestContext, + ctx: &PreparedTurn, ) { payload.output = combined_output; payload.usage = combined_usage; - ctx.inject_ids(payload); + ctx.request().inject_ids(payload); + if let Some(tools) = ctx.tool_search_response_tools() { + payload.tools = Some(tools); + payload.tool_choice = Some(ctx.request().enriched_request.tool_choice.clone().unwrap_or_default()); + } } async fn run_blocking( - ctx: RequestContext, + ctx: PreparedTurn, exec_ctx: &ExecutionContext, auth: Option<&str>, ) -> ExecutorResult { @@ -458,9 +453,9 @@ async fn run_blocking( Ok(payload) } -fn run_stream(ctx: RequestContext, exec_ctx: Arc, auth: Option) -> BoxStream { +fn run_stream(ctx: PreparedTurn, exec_ctx: Arc, auth: Option) -> BoxStream { Box::pin(stream! { - let failure_context = StreamFailureContext::from(&ctx); + let failure_context = StreamFailureContext::from(ctx.request()); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let exec_ctx_for_run = Arc::clone(&exec_ctx); let event_tx_for_run = event_tx.clone(); @@ -556,7 +551,7 @@ impl From<&RequestContext> for StreamFailureContext { } impl StreamFailureContext { - fn failed_payload(&self, error: &crate::executor::error::ExecutorError) -> ResponsePayload { + fn failed_payload(&self, error: &ExecutorError) -> ResponsePayload { ResponsePayload { id: self.response_id.clone(), object: "response".to_owned(), @@ -574,6 +569,8 @@ impl StreamFailureContext { previous_response_id: self.previous_response_id.clone(), conversation_id: self.conversation_id.clone(), instructions: self.instructions.clone(), + tools: None, + tool_choice: None, } } } @@ -659,8 +656,9 @@ impl ExecuteRequest { tools = self.payload.tools.as_ref().map_or(0, Vec::len), "executor received responses request" ); - let ctx = rehydrate_for_execution(self.payload, &self.exec_ctx).await?; - if ctx.original_request.stream { + let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?; + let ctx = prepare_tool_search(ctx, &self.exec_ctx.conv_handler, &self.exec_ctx.resp_handler).await?; + if ctx.request().original_request.stream { Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth))) } else { Ok(Either::Left( @@ -752,72 +750,6 @@ mod tests { (exec_ctx, server) } - #[tokio::test] - async fn restored_loaded_mcp_remains_recoverable_without_history_position() { - let request: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test-model", - "input": "continue after compaction", - "tools": [ - { - "type": "tool_search", - "execution": "client", - "description": "Find a tool", - "parameters": {"type": "object"} - }, - { - "type": "mcp", - "server_label": "private_weather", - "server_url": "http://url-user:url-password@127.0.0.1:1/mcp?token=query-secret", - "require_approval": "never", - "defer_loading": true - } - ], - "parallel_tool_calls": false - })) - .expect("valid tool-search continuation"); - let loaded_mcp = request - .tools - .as_deref() - .expect("tools") - .iter() - .find(|tool| matches!(tool, ResponsesTool::Mcp(_))) - .expect("MCP declaration") - .clone(); - - let state = ToolSearchState::build_with_loaded_tools(&request, &[loaded_mcp], false) - .expect("compaction metadata restores loaded MCP definition"); - - assert!(state.mcp_load_positions().is_empty()); - let loaded_mcp_server_labels = loaded_mcp_server_labels(Some(&state)); - assert_eq!(loaded_mcp_server_labels, HashSet::from(["private_weather".to_owned()])); - - let mut private_tools = state - .private_inference_request(&request) - .expect("restored state materializes private request") - .tools - .expect("private tools"); - let registry = ToolRegistry::build_with_handlers_for_tool_search( - &mut private_tools, - &mut crate::tool::GatewayExecutors::default(), - &loaded_mcp_server_labels, - ) - .await - .expect("restored loaded MCP transport failure becomes public discovery failure"); - - let [list_tools] = registry.mcp_list_tools_items() else { - panic!("expected one restored MCP list-tools failure item"); - }; - assert_eq!(list_tools.server_label, "private_weather"); - assert_eq!( - list_tools.error.as_deref(), - Some("MCP server 'private_weather' failed to connect or list tools") - ); - let public_item = serde_json::to_string(list_tools).expect("public list-tools item serializes"); - for secret in ["url-user", "url-password", "query-secret"] { - assert!(!public_item.contains(secret), "public list-tools item leaked {secret}"); - } - } - #[tokio::test] async fn compaction_trigger_returns_single_compaction_item_without_upstream_trigger() { let captured = Arc::new(Mutex::new(None)); diff --git a/crates/agentic-server-core/src/executor/error.rs b/crates/agentic-server-core/src/executor/error.rs index 9e7a8ee7..0fe6d4d6 100644 --- a/crates/agentic-server-core/src/executor/error.rs +++ b/crates/agentic-server-core/src/executor/error.rs @@ -92,12 +92,7 @@ impl ExecutorError { pub(crate) fn is_invalid_upstream_tool_search(&self) -> bool { matches!( self, - Self::Tool(ToolError::Execution(message)) - if matches!( - message.as_str(), - "upstream returned an invalid tool-search call" - | "upstream returned a call for a function that has not been loaded" - ) + Self::Tool(ToolError::InvalidUpstreamToolSearch | ToolError::UpstreamWithheldFunctionCall) ) } @@ -126,7 +121,12 @@ impl ExecutorError { | Self::Tool(ToolError::Config(_)) | Self::InvalidRequest(_) | Self::JsonError(_) => StatusCode::BAD_REQUEST, - Self::Tool(ToolError::Execution(_)) | Self::CompactionFailed { .. } => StatusCode::BAD_GATEWAY, + Self::Tool( + ToolError::Execution(_) + | ToolError::InvalidUpstreamToolSearch + | ToolError::UpstreamWithheldFunctionCall, + ) + | Self::CompactionFailed { .. } => StatusCode::BAD_GATEWAY, Self::ParseError(_) => StatusCode::UNPROCESSABLE_ENTITY, _ => StatusCode::INTERNAL_SERVER_ERROR, } @@ -143,7 +143,11 @@ impl ExecutorError { | Self::JsonError(_) => "invalid_request_error", Self::Storage(e) if e.is_not_found() => "not_found", Self::LLMRequest { .. } | Self::LLMTransport { .. } | Self::CompactionFailed { .. } => "upstream_error", - Self::Tool(ToolError::Execution(_)) => "tool_error", + Self::Tool( + ToolError::Execution(_) + | ToolError::InvalidUpstreamToolSearch + | ToolError::UpstreamWithheldFunctionCall, + ) => "tool_error", _ => "server_error", } } diff --git a/crates/agentic-server-core/src/executor/function_sse.rs b/crates/agentic-server-core/src/executor/function_sse.rs index 76537d32..bbed1c97 100644 --- a/crates/agentic-server-core/src/executor/function_sse.rs +++ b/crates/agentic-server-core/src/executor/function_sse.rs @@ -6,29 +6,19 @@ 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, search}; -use crate::types::io::OutputItem; +use crate::tool::{ToolType, tool_search}; +use crate::types::io::{OutputItem, ToolSearchCall}; +use crate::types::tools::ToolSearchStatus; use crate::utils::common::{serialize_to_string, serialize_to_value}; const MAX_PENDING_FUNCTION_BYTES: usize = 256 * 1024; -const MAX_PENDING_FUNCTION_CALLS: usize = 128; #[derive(Debug)] enum FunctionCallShape { PublicFunction, GatewayOwned, Custom(CustomCallState), - ToolSearch(ToolSearchCallState), -} - -#[derive(Debug)] -struct ToolSearchCallState { - upstream_item_id: String, - public_item_id: String, - call_id: String, - output_index: u32, - accounted_argument_bytes: usize, - arguments_done: bool, + ToolSearch, } #[derive(Debug)] @@ -64,7 +54,6 @@ pub(super) struct FunctionSseTranslator { pending_unnamed: HashMap, pending_bytes: usize, first_gateway_output_index: Option, - search_call_seen: bool, tool_search_enabled: bool, withheld_function_names: HashSet, upstream_terminal_failure: bool, @@ -89,6 +78,7 @@ impl FunctionSseTranslator { &mut self, frame: EventFrame, call: Option>, + tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { if matches!( frame.event_type, @@ -103,7 +93,7 @@ impl FunctionSseTranslator { output_index, name: Some(name), .. - } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call), + } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call, None), EventPayload::OutputItemAdded { item_id: _, item_type: SSEItemType::FunctionCall, @@ -116,11 +106,10 @@ impl FunctionSseTranslator { } => self.translate_delta(item_id, *output_index, frame.clone(), call), EventPayload::FunctionCallArgsDone { item_id, - call_id, name, output_index, .. - } => self.finish_arguments(item_id, name, *output_index, call_id.as_deref(), frame.clone(), call), + } => self.finish_arguments(item_id, name, *output_index, frame.clone(), call), EventPayload::OutputItemDone { item_id, item_type: SSEItemType::FunctionCall, @@ -128,7 +117,7 @@ impl FunctionSseTranslator { 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) + self.finish_call(item_id, name, *output_index, frame.clone(), call, tool_search_call) } _ => Ok(FunctionSseTranslation { frames: vec![frame], @@ -144,25 +133,16 @@ impl FunctionSseTranslator { && (self .active .values() - .any(|shape| matches!(shape, FunctionCallShape::ToolSearch(_))) + .any(|shape| matches!(shape, FunctionCallShape::ToolSearch)) || (self.tool_search_enabled && !self.pending_unnamed.is_empty())) { - return Err(search::invalid_upstream_search_call().into()); + return Err(tool_search::invalid_upstream_search_call().into()); } Ok(()) } pub(super) fn unfinished_search_item_ids(&self) -> HashSet<&str> { - let mut item_ids = self - .active - .values() - .filter_map(|shape| match shape { - FunctionCallShape::ToolSearch(state) => Some(state.upstream_item_id.as_str()), - FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned | FunctionCallShape::Custom(_) => { - None - } - }) - .collect::>(); + let mut item_ids = HashSet::new(); if self.tool_search_enabled { item_ids.extend(self.pending_unnamed.values().flat_map(|pending| { pending.frames.iter().filter_map(|frame| match &frame.payload { @@ -183,27 +163,19 @@ impl FunctionSseTranslator { match &frame.payload { EventPayload::OutputItemAdded { item_type: SSEItemType::FunctionCall, - output_index, name: None, .. - } => self.validate_pending_frame_before_accumulation(*output_index, frame), + } => self.validate_pending_frame_before_accumulation(frame), EventPayload::FunctionCallArgsDone { arguments, - item_id, name, output_index, - call_id, - } if self.tool_search_enabled - && (name == "tool_search" - || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch(_)))) => + .. + } if self.tool_type(name) == ToolType::ToolSearch + || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch)) => { - validate_wire_output_index(frame, *output_index)?; if arguments.len() > MAX_PENDING_FUNCTION_BYTES { - return Err(search::invalid_upstream_search_call().into()); - } - if let Some(FunctionCallShape::ToolSearch(state)) = self.active.get(output_index) { - validate_active_tool_search_done_name(frame)?; - validate_stream_linkage(state, item_id, call_id.as_deref())?; + return Err(tool_search::invalid_upstream_search_call().into()); } Ok(()) } @@ -212,52 +184,35 @@ impl FunctionSseTranslator { output_index, item, .. - } if self.tool_search_enabled - && (item.get("name").and_then(Value::as_str) == Some("tool_search") - || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch(_)))) => + } if item + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| self.tool_type(name) == ToolType::ToolSearch) + || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch)) => { - validate_wire_output_index(frame, *output_index)?; - let object = item.as_object().ok_or_else(search::invalid_upstream_search_call)?; - let arguments = object + if item .get("arguments") .and_then(Value::as_str) - .ok_or_else(search::invalid_upstream_search_call)?; - if arguments.len() > MAX_PENDING_FUNCTION_BYTES { - return Err(search::invalid_upstream_search_call().into()); - } - let public = search::public_output_item_from_raw(object)?; - if let Some(FunctionCallShape::ToolSearch(state)) = self.active.get(output_index) { - let OutputItem::ToolSearchCall(public) = public else { - return Err(search::invalid_upstream_search_call().into()); - }; - if public.id != state.public_item_id || public.call_id != state.call_id { - return Err(search::invalid_upstream_search_call().into()); - } + .is_some_and(|arguments| arguments.len() > MAX_PENDING_FUNCTION_BYTES) + { + return Err(tool_search::invalid_upstream_search_call().into()); } Ok(()) } EventPayload::FunctionCallArgsDelta { - delta, - call_id, - item_id, - output_index, + delta, output_index, .. } => { let Some(shape) = self.active.get_mut(output_index) else { - return self.validate_pending_frame_before_accumulation(*output_index, frame); + return self.validate_pending_frame_before_accumulation(frame); }; match shape { FunctionCallShape::Custom(_) => { let current = call.map_or(0, |call| call.arguments().len()); ensure_function_call_size_for(current, delta.len()) } - FunctionCallShape::ToolSearch(state) => { - validate_wire_output_index(frame, *output_index)?; - validate_stream_linkage(state, item_id, call_id.as_deref())?; - if state.accounted_argument_bytes.saturating_add(delta.len()) > MAX_PENDING_FUNCTION_BYTES { - return Err(search::invalid_upstream_search_call().into()); - } - state.accounted_argument_bytes = state.accounted_argument_bytes.saturating_add(delta.len()); - Ok(()) + FunctionCallShape::ToolSearch => { + ensure_function_call_size_for(call.map_or(0, |call| call.arguments().len()), delta.len()) + .map_err(|_| tool_search::invalid_upstream_search_call().into()) } FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned => Ok(()), } @@ -299,18 +254,12 @@ impl FunctionSseTranslator { }; if terminal_has_withheld_call || lifecycle_name.is_some_and(|name| self.withheld_function_names.contains(name)) { - return Err(search::invalid_upstream_withheld_function_call().into()); + return Err(tool_search::invalid_upstream_withheld_function_call().into()); } Ok(()) } - fn validate_pending_frame_before_accumulation(&self, output_index: u32, frame: &EventFrame) -> ExecutorResult<()> { - if !self.pending_unnamed.contains_key(&output_index) && self.pending_unnamed.len() >= MAX_PENDING_FUNCTION_CALLS - { - return Err(self.pending_limit_error(format!( - "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_CALLS} pending calls" - ))); - } + fn validate_pending_frame_before_accumulation(&self, frame: &EventFrame) -> ExecutorResult<()> { let bytes = serialize_to_string(&frame.wire) .map_err(ExecutorError::JsonError)? .len(); @@ -324,7 +273,7 @@ impl FunctionSseTranslator { fn pending_limit_error(&self, message: String) -> ExecutorError { if self.tool_search_enabled { - search::invalid_upstream_search_call().into() + tool_search::invalid_upstream_search_call().into() } else { ExecutorError::StreamError(message) } @@ -337,6 +286,7 @@ impl FunctionSseTranslator { output_index: u32, original: Option, call: Option>, + tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { match self.tool_type(name) { ToolType::Custom => { @@ -372,32 +322,19 @@ impl FunctionSseTranslator { Ok(FunctionSseTranslation::default()) } ToolType::ToolSearch => { - if self.search_call_seen - || self.active.contains_key(&output_index) - || self.pending_unnamed.contains_key(&output_index) - { - return Err(search::invalid_upstream_search_call().into()); - } - let call = call.ok_or_else(search::invalid_upstream_search_call)?; - if call.item.namespace.is_some() { - return Err(search::invalid_upstream_search_call().into()); - } - let original = original.as_ref().ok_or_else(search::invalid_upstream_search_call)?; - validate_wire_output_index(original, output_index)?; - let public_item = search::public_added_item(item_id, &call.item.call_id)?; - let public_item_id = public_item["id"].as_str().unwrap_or_default().to_owned(); - self.search_call_seen = true; - self.active.insert( - output_index, - FunctionCallShape::ToolSearch(ToolSearchCallState { - upstream_item_id: item_id.to_owned(), - public_item_id, - call_id: call.item.call_id.clone(), - output_index, - accounted_argument_bytes: call.arguments().len(), - arguments_done: false, - }), - ); + let started = if let Some(call) = call { + ToolSearchCall::started_from_function(call.item)? + } else { + let mut started = tool_search_call + .cloned() + .ok_or_else(tool_search::invalid_upstream_search_call)?; + started.arguments.clear(); + started.status = ToolSearchStatus::InProgress; + started + }; + let public_item = + serialize_to_value(&OutputItem::ToolSearchCall(started)).map_err(ExecutorError::JsonError)?; + self.active.insert(output_index, FunctionCallShape::ToolSearch); Ok(FunctionSseTranslation { frames: vec![tool_search_frame( SSEEventType::OutputItemAdded, @@ -419,7 +356,7 @@ impl FunctionSseTranslator { fn translate_delta( &mut self, - item_id: &str, + _item_id: &str, output_index: u32, original: EventFrame, call: Option>, @@ -429,7 +366,9 @@ impl FunctionSseTranslator { frames: vec![original], defer_from_output_index: None, }), - Some(FunctionCallShape::GatewayOwned) => Ok(FunctionSseTranslation::default()), + Some(FunctionCallShape::GatewayOwned | FunctionCallShape::ToolSearch) => { + Ok(FunctionSseTranslation::default()) + } Some(FunctionCallShape::Custom(state)) => { let frame = match call { Some(call) => incremental_custom_delta(state, call.arguments())?, @@ -440,14 +379,6 @@ impl FunctionSseTranslator { defer_from_output_index: None, }) } - Some(FunctionCallShape::ToolSearch(state)) => { - let event_call_id = match &original.payload { - EventPayload::FunctionCallArgsDelta { call_id, .. } => call_id.as_deref(), - _ => None, - }; - validate_stream_linkage(state, item_id, event_call_id)?; - Ok(FunctionSseTranslation::default()) - } None => self.buffer_unnamed(output_index, original), } } @@ -457,33 +388,18 @@ impl FunctionSseTranslator { item_id: &str, name: &str, output_index: u32, - event_call_id: Option<&str>, original: EventFrame, call: Option>, ) -> ExecutorResult { - let mut translated = self.resolve_pending(item_id, name, output_index, call)?; + let mut translated = self.resolve_pending(item_id, name, output_index, call, None)?; match self.active.get_mut(&output_index) { Some(FunctionCallShape::PublicFunction) | None => translated.frames.push(original), - Some(FunctionCallShape::GatewayOwned) => {} + Some(FunctionCallShape::GatewayOwned | FunctionCallShape::ToolSearch) => {} Some(FunctionCallShape::Custom(state)) => { if let Some(call) = call { translated.frames.extend(finish_custom_input(state, call.arguments())?); } } - Some(FunctionCallShape::ToolSearch(state)) => { - validate_active_tool_search_done_name(&original)?; - validate_stream_linkage(state, item_id, event_call_id)?; - let call = call.ok_or_else(search::invalid_upstream_search_call)?; - validate_search_call_state(state, &call, item_id)?; - let public = search::public_output_item(&call.item.id, &call.item.call_id, call.arguments())?; - let OutputItem::ToolSearchCall(public) = public else { - return Err(search::invalid_upstream_search_call().into()); - }; - if public.id != state.public_item_id || public.call_id != state.call_id { - return Err(search::invalid_upstream_search_call().into()); - } - state.arguments_done = true; - } } Ok(translated) } @@ -495,8 +411,9 @@ impl FunctionSseTranslator { output_index: u32, original: EventFrame, call: Option>, + tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { - let mut translated = self.resolve_pending(item_id, name, output_index, call)?; + let mut translated = self.resolve_pending(item_id, name, output_index, call, tool_search_call)?; match self.active.remove(&output_index) { Some(FunctionCallShape::PublicFunction) | None => translated.frames.push(original), Some(FunctionCallShape::GatewayOwned) => {} @@ -508,32 +425,13 @@ impl FunctionSseTranslator { translated.frames.push(custom_done_frame(&state, &call)?); } } - Some(FunctionCallShape::ToolSearch(state)) => { - let call = call.ok_or_else(search::invalid_upstream_search_call)?; - validate_search_call_state(&state, &call, item_id)?; - let object = original - .wire - .rest - .get("item") - .and_then(Value::as_object) - .ok_or_else(search::invalid_upstream_search_call)?; - let public = search::public_output_item_from_raw(object)?; - let OutputItem::ToolSearchCall(public_call) = &public else { - return Err(search::invalid_upstream_search_call().into()); - }; - if public_call.id != state.public_item_id - || public_call.call_id != state.call_id - || public_call.arguments != search_arguments(&call)? - || (!state.arguments_done && call.arguments().is_empty()) - { - return Err(search::invalid_upstream_search_call().into()); - } - let item = serialize_to_value(&public).map_err(ExecutorError::JsonError)?; - translated.frames.push(tool_search_frame( - SSEEventType::OutputItemDone, - state.output_index, - item, - )?); + Some(FunctionCallShape::ToolSearch) => { + let public_call = tool_search_call.ok_or_else(tool_search::invalid_upstream_search_call)?; + let item = serialize_to_value(&OutputItem::ToolSearchCall(public_call.clone())) + .map_err(ExecutorError::JsonError)?; + translated + .frames + .push(tool_search_frame(SSEEventType::OutputItemDone, output_index, item)?); } } Ok(translated) @@ -545,13 +443,14 @@ impl FunctionSseTranslator { name: &str, output_index: u32, call: Option>, + tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { if self.active.contains_key(&output_index) { return Ok(FunctionSseTranslation::default()); } let pending = self.take_pending(output_index); - let added = pending.iter().filter(|frame| { + let original_added = pending.iter().find(|frame| { matches!( frame.payload, EventPayload::OutputItemAdded { @@ -560,11 +459,6 @@ impl FunctionSseTranslator { } ) }); - let added = added.collect::>(); - if self.tool_type(name) == ToolType::ToolSearch && added.len() != 1 { - return Err(search::invalid_upstream_search_call().into()); - } - let original_added = added.first().copied(); let start_item_id = original_added.and_then(|frame| match &frame.payload { EventPayload::OutputItemAdded { item_id, .. } => Some(item_id.as_str()), _ => None, @@ -575,6 +469,7 @@ impl FunctionSseTranslator { output_index, original_added.cloned(), call, + tool_search_call, )?; for frame in pending { @@ -609,12 +504,6 @@ impl FunctionSseTranslator { "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" ))); } - if !self.pending_unnamed.contains_key(&output_index) && self.pending_unnamed.len() >= MAX_PENDING_FUNCTION_CALLS - { - return Err(self.pending_limit_error(format!( - "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_CALLS} pending calls" - ))); - } let pending = self .pending_unnamed .entry(output_index) @@ -637,57 +526,6 @@ impl FunctionSseTranslator { } } -fn validate_search_call_state( - state: &ToolSearchCallState, - call: &AccumulatedFunctionCall<'_>, - event_item_id: &str, -) -> ExecutorResult<()> { - ensure_function_call_size(call.arguments())?; - if call.output_index != state.output_index - || call.item.call_id != state.call_id - || call.item.id != state.upstream_item_id - || event_item_id != state.upstream_item_id - { - return Err(search::invalid_upstream_search_call().into()); - } - Ok(()) -} - -fn validate_stream_linkage(state: &ToolSearchCallState, item_id: &str, call_id: Option<&str>) -> ExecutorResult<()> { - if item_id != state.upstream_item_id || call_id.is_some_and(|call_id| call_id != state.call_id) { - return Err(search::invalid_upstream_search_call().into()); - } - Ok(()) -} - -fn validate_active_tool_search_done_name(frame: &EventFrame) -> ExecutorResult<()> { - if frame - .wire - .rest - .get("name") - .is_some_and(|name| name.as_str() != Some("tool_search")) - { - return Err(search::invalid_upstream_search_call().into()); - } - Ok(()) -} - -fn validate_wire_output_index(frame: &EventFrame, output_index: u32) -> ExecutorResult<()> { - if frame.wire.output_index != Some(u64::from(output_index)) { - return Err(search::invalid_upstream_search_call().into()); - } - Ok(()) -} - -fn search_arguments(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult> { - let OutputItem::ToolSearchCall(public) = - search::public_output_item(&call.item.id, &call.item.call_id, call.arguments())? - else { - return Err(search::invalid_upstream_search_call().into()); - }; - Ok(public.arguments) -} - fn tool_search_frame(event_type: SSEEventType, output_index: u32, item: Value) -> ExecutorResult { let mut frame = synthetic_event(event_type, [("item".to_owned(), item)])?; frame.wire.output_index = Some(u64::from(output_index)); @@ -910,6 +748,8 @@ fn custom_input_start(arguments: &str) -> Option { mod tests { use super::*; use crate::executor::accumulator::ResponseAccumulator; + use crate::types::event::MessageStatus; + use crate::types::io::FunctionToolCall; fn sse(value: &Value) -> String { format!("data: {value}") @@ -922,10 +762,19 @@ mod tests { ) -> FunctionSseTranslation { accumulator .process_sse_line_with_translator(&sse(value), translator) - .expect("translation succeeds") + .unwrap_or_else(|error| panic!("translation succeeds for {value}: {error}")) .expect("SSE event") } + fn tool_search_accumulator(response_id: &str) -> ResponseAccumulator { + ResponseAccumulator::new(response_id.to_owned(), None) + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .expect("tool-search accumulator configuration is valid") + } + fn search_event_sequence(item_id: &str, call_id: &str, arguments: &str) -> [Value; 5] { let split = arguments.len() / 2; let (first, second) = arguments.split_at(split); @@ -957,7 +806,7 @@ mod tests { #[test] fn tool_search_stream_emits_only_public_added_and_done_with_stable_identity() { - let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut accumulator = tool_search_accumulator("resp_1"); let mut translator = FunctionSseTranslator::new(HashMap::from([ ("tool_search".to_owned(), ToolType::ToolSearch), ("weather".to_owned(), ToolType::Function), @@ -1014,8 +863,17 @@ mod tests { ); assert_eq!(frames[1].wire.rest["item"]["type"], "function_call"); - let blocking = search::public_output_item("fc_search", "call_search", arguments) - .expect("blocking translation uses the same identity helper"); + let blocking = OutputItem::ToolSearchCall( + ToolSearchCall::try_from(&FunctionToolCall { + id: "fc_search".to_owned(), + call_id: "call_search".to_owned(), + name: "tool_search".to_owned(), + namespace: None, + arguments: arguments.to_owned(), + status: MessageStatus::Completed, + }) + .expect("blocking translation uses the same typed conversion"), + ); let blocking = serialize_to_value(&blocking).expect("blocking item serializes"); let replay: crate::types::io::InputItem = serde_json::from_value(blocking.clone()).expect("public item replays"); @@ -1027,7 +885,7 @@ mod tests { #[test] fn tool_search_stream_rejects_malformed_arguments_and_empty_call_id() { for (call_id, arguments) in [("call_search", "[1]"), ("call_search", "{"), ("", "{}")] { - let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut accumulator = tool_search_accumulator("resp_1"); let mut translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let mut error = None; @@ -1050,87 +908,32 @@ mod tests { } #[test] - fn tool_search_stream_rejects_linkage_changes_second_call_and_premature_eof() { - let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + fn tool_search_stream_rejects_premature_eof() { + let mut accumulator = tool_search_accumulator("resp_1"); let mut translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let added = &search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#)[0]; translate(&mut accumulator, &mut translator, added); - let wrong_item = serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 0, - "item_id": "fc_other", "delta": "{}" - }); - assert!( - accumulator - .process_sse_line_with_translator(&sse(&wrong_item), &mut translator) - .expect_err("changed item ID must fail") - .to_string() - .contains("invalid tool-search call") - ); assert!( translator.finish().is_err(), "an unfinished search call must fail at EOF" ); - - let mut unnamed_accumulator = ResponseAccumulator::new("resp_unnamed".to_owned(), None); - let mut unnamed_translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - for event in [ - serde_json::json!({ - "type": "response.output_item.added", "output_index": 0, - "item": {"id": "fc_expected", "type": "function_call", "call_id": "call_expected", - "arguments": "", "status": "in_progress"} - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 0, - "item_id": "fc_wrong", "delta": "{}" - }), - ] { - unnamed_accumulator - .process_sse_line_with_translator(&sse(&event), &mut unnamed_translator) - .expect("unnamed frames buffer before resolution"); - } - let resolving_done = serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_expected", "name": "tool_search", "arguments": "{}" - }); - assert!( - unnamed_accumulator - .process_sse_line_with_translator(&sse(&resolving_done), &mut unnamed_translator) - .expect_err("buffered delta item linkage must be validated") - .to_string() - .contains("invalid tool-search call") - ); - - let mut second_accumulator = ResponseAccumulator::new("resp_2".to_owned(), None); - let mut second_translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - for event in search_event_sequence("fc_first", "call_first", "{}") { - translate(&mut second_accumulator, &mut second_translator, &event); - } - let second_added = serde_json::json!({ - "type": "response.output_item.added", "output_index": 1, - "item": {"id": "fc_second", "type": "function_call", "call_id": "call_second", - "name": "tool_search", "arguments": "", "status": "in_progress"} - }); - assert!( - second_accumulator - .process_sse_line_with_translator(&sse(&second_added), &mut second_translator) - .expect_err("a second synthetic search call must fail") - .to_string() - .contains("invalid tool-search call") - ); } #[test] fn tool_search_stream_accepts_authoritative_done_without_argument_deltas() { - let mut accumulator = ResponseAccumulator::new("resp_done_only".to_owned(), None); + let mut accumulator = tool_search_accumulator("resp_done_only"); let mut translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let events = search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#); - - let frames = [0, 3, 4] + let mut events = search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#); + events[0] + .get_mut("item") + .and_then(Value::as_object_mut) + .expect("added function item") + .remove("name"); + + let frames = [0, 4] .into_iter() .flat_map(|index| translate(&mut accumulator, &mut translator, &events[index]).frames) .collect::>(); @@ -1148,7 +951,7 @@ mod tests { #[test] fn tool_search_stream_accepts_omitted_done_name_for_active_call() { - let mut accumulator = ResponseAccumulator::new("resp_omitted_done_name".to_owned(), None); + let mut accumulator = tool_search_accumulator("resp_omitted_done_name"); let mut translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let arguments = r#"{"query": "add numbers"}"#; @@ -1211,109 +1014,6 @@ mod tests { assert!(translator.finish().is_ok()); } - #[test] - fn tool_search_stream_rejects_authoritative_shape_and_linkage_mismatches() { - let added = search_event_sequence("fc_search", "call_search", "{}")[0].clone(); - for (label, followup) in [ - ( - "wrong call id", - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 0, - "item_id": "fc_search", "call_id": "call_other", "delta": "{}" - }), - ), - ( - "missing output index", - serde_json::json!({ - "type": "response.function_call_arguments.delta", - "item_id": "fc_search", "call_id": "call_search", "delta": "{}" - }), - ), - ( - "wrong done name", - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_search", "call_id": "call_search", "name": "weather", - "arguments": "{}" - }), - ), - ( - "empty done name", - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_search", "call_id": "call_search", "name": "", "arguments": "{}" - }), - ), - ( - "null done name", - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_search", "call_id": "call_search", "name": null, "arguments": "{}" - }), - ), - ( - "non-string done name", - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_search", "call_id": "call_search", "name": 7, "arguments": "{}" - }), - ), - ] { - let mut accumulator = ResponseAccumulator::new(format!("resp_{label}"), None); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - translate(&mut accumulator, &mut translator, &added); - - let error = accumulator - .process_sse_line_with_translator(&sse(&followup), &mut translator) - .expect_err(label); - assert!(error.is_invalid_upstream_tool_search(), "{label}: {error}"); - } - - for (label, invalid_added) in [ - ( - "namespace on added", - serde_json::json!({ - "type": "response.output_item.added", "output_index": 0, - "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", - "name": "tool_search", "namespace": "tools", "arguments": "", "status": "in_progress"} - }), - ), - ( - "missing added output index", - serde_json::json!({ - "type": "response.output_item.added", - "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", - "name": "tool_search", "arguments": "", "status": "in_progress"} - }), - ), - ] { - let mut accumulator = ResponseAccumulator::new(format!("resp_{label}"), None); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let error = accumulator - .process_sse_line_with_translator(&sse(&invalid_added), &mut translator) - .expect_err(label); - assert!(error.is_invalid_upstream_tool_search(), "{label}: {error}"); - } - - let mut accumulator = ResponseAccumulator::new("resp_index_collision".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([ - ("tool_search".to_owned(), ToolType::ToolSearch), - ("weather".to_owned(), ToolType::Function), - ])); - let ordinary = serde_json::json!({ - "type": "response.output_item.added", "output_index": 0, - "item": {"id": "fc_weather", "type": "function_call", "call_id": "call_weather", - "name": "weather", "arguments": "", "status": "in_progress"} - }); - translate(&mut accumulator, &mut translator, &ordinary); - let error = accumulator - .process_sse_line_with_translator(&sse(&added), &mut translator) - .expect_err("search must not overwrite an active output index"); - assert!(error.is_invalid_upstream_tool_search(), "{error}"); - } - #[test] fn unfinished_search_ids_include_pending_candidates_with_other_loaded_tools_only_until_resolved() { let tool_types = HashMap::from([ @@ -1326,7 +1026,7 @@ mod tests { "arguments": "", "status": "in_progress"} }); - let mut pending_accumulator = ResponseAccumulator::new("resp_pending".to_owned(), None); + let mut pending_accumulator = tool_search_accumulator("resp_pending"); let mut pending_translator = FunctionSseTranslator::new(tool_types.clone()); translate(&mut pending_accumulator, &mut pending_translator, &unnamed); assert_eq!( @@ -1348,7 +1048,7 @@ mod tests { #[test] fn upstream_failure_may_terminate_an_incomplete_search_without_false_completion() { - let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut accumulator = tool_search_accumulator("resp_1"); let mut translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let added = &search_event_sequence("fc_search", "call_search", "{}")[0]; @@ -1369,31 +1069,11 @@ mod tests { } #[test] - fn pending_function_stream_state_has_aggregate_byte_and_call_count_limits() { - let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::new()); - for output_index in 0..MAX_PENDING_FUNCTION_CALLS { - let unnamed = serde_json::json!({ - "type": "response.output_item.added", "output_index": output_index, - "item": {"id": format!("fc_{output_index}"), "type": "function_call", - "call_id": format!("call_{output_index}"), "arguments": "", "status": "in_progress"} - }); - translate(&mut accumulator, &mut translator, &unnamed); - } - let over_count = serde_json::json!({ - "type": "response.output_item.added", "output_index": MAX_PENDING_FUNCTION_CALLS, - "item": {"id": "fc_over", "type": "function_call", "call_id": "call_over", - "arguments": "", "status": "in_progress"} - }); - let count_error = accumulator - .process_sse_line_with_translator(&sse(&over_count), &mut translator) - .expect_err("pending call count must be bounded"); - assert!(count_error.to_string().contains("pending calls")); - + fn pending_function_stream_state_has_aggregate_byte_limit() { let mut bytes_accumulator = ResponseAccumulator::new("resp_2".to_owned(), None); let mut bytes_translator = FunctionSseTranslator::new(HashMap::new()); let mut byte_error = None; - for output_index in 0..MAX_PENDING_FUNCTION_CALLS { + for output_index in 0..128 { let unnamed = serde_json::json!({ "type": "response.output_item.added", "output_index": output_index, "item": {"id": format!("fc_bytes_{output_index}"), "type": "function_call", @@ -1423,29 +1103,6 @@ mod tests { .to_string() .contains("unnamed function-call SSE exceeded") ); - - let mut search_accumulator = ResponseAccumulator::new("resp_search_pending".to_owned(), None); - let mut search_translator = FunctionSseTranslator::new(HashMap::from([ - ("tool_search".to_owned(), ToolType::ToolSearch), - ("weather".to_owned(), ToolType::Function), - ])); - for output_index in 0..MAX_PENDING_FUNCTION_CALLS { - let unnamed = serde_json::json!({ - "type": "response.output_item.added", "output_index": output_index, - "item": {"id": format!("fc_search_{output_index}"), "type": "function_call", - "call_id": format!("call_search_{output_index}"), "arguments": "", "status": "in_progress"} - }); - translate(&mut search_accumulator, &mut search_translator, &unnamed); - } - let over_count = serde_json::json!({ - "type": "response.output_item.added", "output_index": MAX_PENDING_FUNCTION_CALLS, - "item": {"id": "fc_search_over", "type": "function_call", "call_id": "call_search_over", - "arguments": "", "status": "in_progress"} - }); - let error = search_accumulator - .process_sse_line_with_translator(&sse(&over_count), &mut search_translator) - .expect_err("search-active pending overflow must use invalid-search classification"); - assert!(error.is_invalid_upstream_tool_search(), "{error}"); } #[test] @@ -1458,7 +1115,7 @@ mod tests { ); assert_eq!(exact_arguments.len(), MAX_PENDING_FUNCTION_BYTES); - let mut exact_accumulator = ResponseAccumulator::new("resp_exact".to_owned(), None); + let mut exact_accumulator = tool_search_accumulator("resp_exact"); let mut exact_translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let exact_events = search_event_sequence("fc_exact", "call_exact", &exact_arguments); @@ -1475,7 +1132,7 @@ mod tests { ); let over_arguments = format!("{exact_arguments}x"); - let mut over_accumulator = ResponseAccumulator::new("resp_over".to_owned(), None); + let mut over_accumulator = tool_search_accumulator("resp_over"); let mut over_translator = FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); let over_events = search_event_sequence("fc_over", "call_over", &over_arguments); diff --git a/crates/agentic-server-core/src/executor/gateway.rs b/crates/agentic-server-core/src/executor/gateway.rs index 8857e226..b2e48cd0 100644 --- a/crates/agentic-server-core/src/executor/gateway.rs +++ b/crates/agentic-server-core/src/executor/gateway.rs @@ -197,6 +197,10 @@ async fn execute_gateway_call_with_timeout( Err(ToolError::Execution(message) | ToolError::Config(message)) => { (execution_error_output(&call, &message)?, GatewayCallStatus::Failed) } + Err(error @ (ToolError::InvalidUpstreamToolSearch | ToolError::UpstreamWithheldFunctionCall)) => ( + execution_error_output(&call, &error.to_string())?, + GatewayCallStatus::Failed, + ), }; let public_output = gateway_public_output(dispatch.tool_type, &call, &output, status, registry); Ok(GatewayCallResult { @@ -587,7 +591,7 @@ pub(super) fn append_output_items_to_input(input: &mut ResponsesInput, output_it pub(super) fn append_tool_outputs(ctx: &mut RequestContext, tool_outputs: Vec) { for output in tool_outputs { ctx.new_input_items.push(output.clone()); - append_input_item(&mut ctx.inference_request_mut().input, output); + append_input_item(&mut ctx.enriched_request.input, output); } } diff --git a/crates/agentic-server-core/src/executor/mod.rs b/crates/agentic-server-core/src/executor/mod.rs index 48401b20..6023f0a5 100644 --- a/crates/agentic-server-core/src/executor/mod.rs +++ b/crates/agentic-server-core/src/executor/mod.rs @@ -28,6 +28,6 @@ pub use messages_request::{normalize_native_web_search_for_upstream, validate_na pub use messages_stream::run_messages_stream; pub use modes::{ConversationHandler, ResponseHandler}; pub use persist::{persist_response, persist_turn}; -pub use rehydrate::{rehydrate_conversation, rehydrate_for_execution}; +pub use rehydrate::rehydrate_conversation; pub use request::ExecutionContext; pub use request::RequestContext; diff --git a/crates/agentic-server-core/src/executor/modes/conversation.rs b/crates/agentic-server-core/src/executor/modes/conversation.rs index 2543cf58..0c7d5b00 100644 --- a/crates/agentic-server-core/src/executor/modes/conversation.rs +++ b/crates/agentic-server-core/src/executor/modes/conversation.rs @@ -1,6 +1,9 @@ //! Conversation storage handler — owns all conversation store operations. -use crate::storage::{ConversationData, ConversationSnapshot, ConversationStore, InOutItem, StorageError}; +use crate::storage::{ + ConversationData, ConversationSnapshot, ConversationStore, ConversationVersion, InOutItem, ResponseMetadata, + StorageError, +}; use crate::types::io::OutputItem; use crate::executor::error::{ExecutorError, ExecutorResult}; @@ -89,17 +92,47 @@ impl ConversationHandler { .map_err(ExecutorError::Storage) } + /// Loads metadata for the persisted turn matching a captured conversation version. + /// + /// # Errors + /// Returns `ExecutorError` if `conversation_id` is absent, the store is + /// disabled, or the database query fails. + pub(crate) async fn response_metadata_at_version( + &self, + ctx: &RequestContext, + version: ConversationVersion, + ) -> ExecutorResult> { + let conv_id = ctx.original_request.conversation_id.as_deref().ok_or_else(|| { + ExecutorError::InvalidRequest("conversation_id is required for response metadata lookup".into()) + })?; + self.store + .response_metadata_at_version(conv_id, version) + .await + .map_err(ExecutorError::Storage) + } + /// Persists one conversation turn — only the new items from this turn. /// /// Takes `ctx` and `output_items` by value so fields can be moved directly - /// into [`crate::storage::ResponseMetadata`]. The store tracks sequence + /// into [`ResponseMetadata`]. The store tracks sequence /// numbers and appends, so prior history must not be re-inserted. /// /// # Errors /// Returns `ExecutorError` if `conversation_id` is absent on the context, /// the store is disabled, or the database operation fails. - pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { - let metadata = ctx.response_metadata(); + pub async fn execute_turn(&self, mut ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { + let metadata = ctx.take_response_metadata(); + + self.execute_turn_with_metadata(ctx, output_items, metadata).await + } + + /// Persists a conversation turn using metadata prepared by request-scoped tool behavior. + pub(crate) async fn execute_turn_with_metadata( + &self, + ctx: RequestContext, + output_items: Vec, + metadata: ResponseMetadata, + ) -> ExecutorResult<()> { let conversation_id = ctx .conversation_id .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for execute_turn".into()))?; @@ -163,9 +196,6 @@ mod tests { RequestContext { enriched_request: req.clone(), original_request: req, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_test".into(), conversation_id: conversation_id.map(str::to_string), diff --git a/crates/agentic-server-core/src/executor/modes/response.rs b/crates/agentic-server-core/src/executor/modes/response.rs index 08514f8a..c319748a 100644 --- a/crates/agentic-server-core/src/executor/modes/response.rs +++ b/crates/agentic-server-core/src/executor/modes/response.rs @@ -1,6 +1,6 @@ //! Response storage handler — owns all response store operations. -use crate::storage::{InOutItem, ResponseData, ResponseStore}; +use crate::storage::{InOutItem, ResponseData, ResponseMetadata, ResponseStore}; use crate::types::io::OutputItem; use crate::executor::error::{ExecutorError, ExecutorResult}; @@ -68,9 +68,19 @@ impl ResponseHandler { /// /// # Errors /// Returns `ExecutorError` if the store is disabled or the database operation fails. - pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { - let metadata = ctx.response_metadata(); + pub async fn execute_turn(&self, mut ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { + let metadata = ctx.take_response_metadata(); + self.execute_turn_with_metadata(ctx, output_items, metadata).await + } + + /// Persists a response using metadata prepared by request-scoped tool behavior. + pub(crate) async fn execute_turn_with_metadata( + &self, + ctx: RequestContext, + output_items: Vec, + metadata: ResponseMetadata, + ) -> ExecutorResult<()> { let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len()); new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input)); new_items.extend(output_items.into_iter().map(InOutItem::Output)); @@ -122,9 +132,6 @@ mod tests { RequestContext { enriched_request: req.clone(), original_request: req, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_test".into(), conversation_id: None, diff --git a/crates/agentic-server-core/src/executor/persist.rs b/crates/agentic-server-core/src/executor/persist.rs index c596c51a..6615cb6e 100644 --- a/crates/agentic-server-core/src/executor/persist.rs +++ b/crates/agentic-server-core/src/executor/persist.rs @@ -5,7 +5,8 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::modes::{ConversationHandler, ResponseHandler}; -use crate::executor::request::RequestContext; +use crate::executor::prepare::prepare_tool_search; +use crate::executor::request::{PreparedTurn, RequestContext}; use crate::types::event::ResponseStatus; use crate::types::io::OutputItem; use crate::types::request_response::ResponsePayload; @@ -13,17 +14,19 @@ use tracing::error; #[must_use] pub(crate) fn should_persist(ctx: &RequestContext) -> bool { - ctx.original_request.store || ctx.original_request.conversation_id.is_some() + ctx.original_request.store + || ctx.original_request.previous_response_id.is_some() + || ctx.original_request.conversation_id.is_some() } pub(crate) async fn persist_if_needed( payload: ResponsePayload, - ctx: RequestContext, + ctx: PreparedTurn, conv_handler: ConversationHandler, resp_handler: ResponseHandler, ) -> ExecutorResult<()> { - if should_persist(&ctx) { - persist_response(payload, ctx, conv_handler, resp_handler) + if should_persist(ctx.request()) { + persist_prepared_response(payload, ctx, conv_handler, resp_handler) .await .map_err(|source| { error!(error = ?source, "failed to persist response"); @@ -57,7 +60,25 @@ pub async fn persist_response( return Ok(()); } - persist_turn(ctx, payload.output, &conv_handler, &resp_handler).await + let ctx = prepare_tool_search(ctx, &conv_handler, &resp_handler).await?; + persist_prepared_turn(ctx, payload.output, &conv_handler, &resp_handler).await +} + +async fn persist_prepared_response( + payload: ResponsePayload, + ctx: PreparedTurn, + conv_handler: ConversationHandler, + resp_handler: ResponseHandler, +) -> ExecutorResult<()> { + if !matches!( + payload.status.parse::().unwrap_or_default(), + ResponseStatus::Completed | ResponseStatus::Incomplete + ) || payload.id.is_empty() + { + return Ok(()); + } + + persist_prepared_turn(ctx, payload.output, &conv_handler, &resp_handler).await } /// Persists one completed turn with the handler selected by its explicit conversation discriminator. @@ -70,9 +91,25 @@ pub async fn persist_turn( conv_handler: &ConversationHandler, resp_handler: &ResponseHandler, ) -> ExecutorResult<()> { + let ctx = prepare_tool_search(ctx, conv_handler, resp_handler).await?; + persist_prepared_turn(ctx, output_items, conv_handler, resp_handler).await +} + +pub(crate) async fn persist_prepared_turn( + mut ctx: PreparedTurn, + output_items: Vec, + conv_handler: &ConversationHandler, + resp_handler: &ResponseHandler, +) -> ExecutorResult<()> { + let metadata = ctx.take_response_metadata(); + let ctx = ctx.into_request(); if ctx.original_request.conversation_id.is_some() { - conv_handler.execute_turn(ctx, output_items).await + conv_handler + .execute_turn_with_metadata(ctx, output_items, metadata) + .await } else { - resp_handler.execute_turn(ctx, output_items).await + resp_handler + .execute_turn_with_metadata(ctx, output_items, metadata) + .await } } diff --git a/crates/agentic-server-core/src/executor/prepare.rs b/crates/agentic-server-core/src/executor/prepare.rs index 75ed186c..c1cbfa62 100644 --- a/crates/agentic-server-core/src/executor/prepare.rs +++ b/crates/agentic-server-core/src/executor/prepare.rs @@ -1,28 +1,57 @@ +//! Explicit request-scoped tool preparation after public rehydration. + use crate::executor::error::ExecutorResult; -use crate::executor::request::RequestContext; -use crate::tool::ToolSearchState; +use crate::executor::modes::{ConversationHandler, ResponseHandler}; +use crate::executor::rehydrate::apply_effective_settings; +use crate::executor::request::{PreparedTurn, RequestContext}; +use crate::tool::PreparedToolSearch; +use crate::types::tools::ResponsesTool; -/// Prepare the one pure request-scoped tool-search state after rehydration. -/// -/// Active state is shared by blocking and streaming execution, persistence, -/// continuation, replay, and compaction. +/// Prepare the tool-search projection for a fully rehydrated public request. /// -/// # Errors -/// -/// Returns a client-visible configuration error for invalid state. -pub(crate) fn prepare_tool_search(ctx: &mut RequestContext) -> ExecutorResult<()> { - let state = ToolSearchState::build_with_loaded_tools( - &ctx.enriched_request, - ctx.tool_search_loaded_tools.as_deref().unwrap_or_default(), - ctx.original_request.tools.is_some(), - )?; - if !state.is_active() { - ctx.tool_search_state = Some(state); - return Ok(()); +/// Compaction may remove the call/output pair that records which deferred +/// definitions were loaded. Only that path performs a targeted metadata read; +/// ordinary rehydration does not gain an additional storage query. +pub(crate) async fn prepare_tool_search( + mut ctx: RequestContext, + conv_handler: &ConversationHandler, + resp_handler: &ResponseHandler, +) -> ExecutorResult { + let restored_loaded_tools = restored_loaded_tools(&mut ctx, conv_handler, resp_handler).await?; + let restore_only_declared = ctx.original_request.tools.is_some(); + let tool_search = + PreparedToolSearch::prepare(&mut ctx.enriched_request, &restored_loaded_tools, restore_only_declared)?; + Ok(PreparedTurn::new(ctx, tool_search)) +} + +async fn restored_loaded_tools( + ctx: &mut RequestContext, + conv_handler: &ConversationHandler, + resp_handler: &ResponseHandler, +) -> ExecutorResult> { + if !ctx.enriched_request.input.contains_compaction() { + return Ok(Vec::new()); + } + + if ctx.original_request.previous_response_id.is_some() { + return Ok(resp_handler + .get(ctx) + .await? + .metadata + .tool_search_loaded_tools + .unwrap_or_default()); } - let private_request = state.private_inference_request(&ctx.enriched_request)?; - ctx.tool_search_state = Some(state); - ctx.tool_search_private_request = Some(Box::new(private_request)); - Ok(()) + let Some(version) = ctx.conversation_version else { + return Ok(Vec::new()); + }; + let metadata = conv_handler.response_metadata_at_version(ctx, version).await?; + if let Some(metadata) = metadata { + let restored = metadata.tool_search_loaded_tools.clone().unwrap_or_default(); + if metadata.tool_search_loaded_tools.is_some() { + apply_effective_settings(ctx, &metadata); + } + return Ok(restored); + } + Ok(Vec::new()) } diff --git a/crates/agentic-server-core/src/executor/rehydrate.rs b/crates/agentic-server-core/src/executor/rehydrate.rs index ab01fdd9..4824b0b6 100644 --- a/crates/agentic-server-core/src/executor/rehydrate.rs +++ b/crates/agentic-server-core/src/executor/rehydrate.rs @@ -4,7 +4,6 @@ //! injecting them into the enriched request before it is forwarded to the LLM. use crate::executor::error::{ExecutorError, ExecutorResult}; -use crate::executor::prepare::prepare_tool_search; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::storage::InOutItem; use crate::types::io::{InputItem, ResponsesInput, resolve_tool_choice, resolve_tools}; @@ -36,9 +35,6 @@ pub async fn rehydrate_conversation( let mut ctx = RequestContext { enriched_request: request, original_request, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items, response_id, conversation_id: None, @@ -65,22 +61,6 @@ pub async fn rehydrate_conversation( Ok(ctx) } -/// Rehydrate the complete public request history, then invoke the shared -/// state-preparation seam before registry construction. -/// -/// # Errors -/// -/// Returns rehydration errors or deterministic tool-search state-validation -/// errors. -pub async fn rehydrate_for_execution( - request: RequestPayload, - exec_ctx: &ExecutionContext, -) -> ExecutorResult { - let mut ctx = rehydrate_conversation(request, exec_ctx).await?; - prepare_tool_search(&mut ctx)?; - Ok(ctx) -} - /// Hydrates `ctx` from the previous response chain. /// /// Loads the stored response, rehydrates its history items, resolves effective @@ -117,21 +97,17 @@ async fn from_conversation(ctx: &mut RequestContext, exec_ctx: &ExecutionContext exec_ctx.conv_handler.rehydrate_snapshot(ctx), )?; - let latest_response_metadata = snapshot.latest_response_metadata; let mut items = InOutItem::into_input_items(snapshot.items); items.reserve(ctx.new_input_items.len()); items.extend(ctx.new_input_items.iter().cloned()); ctx.enriched_request.input = ResponsesInput::Items(items); - if let Some(metadata) = latest_response_metadata.as_ref() { - apply_effective_settings(ctx, metadata); - } ctx.conversation_id = Some(conv_data.conversation_id); ctx.conversation_version = Some(snapshot.version); Ok(()) } -fn apply_effective_settings(ctx: &mut RequestContext, stored: &crate::storage::ResponseMetadata) { +pub(crate) fn apply_effective_settings(ctx: &mut RequestContext, stored: &crate::storage::ResponseMetadata) { let tools_explicitly_set = ctx.original_request.tools.is_some(); ctx.enriched_request.tools = resolve_tools( ctx.original_request.tools.as_deref(), @@ -143,8 +119,6 @@ fn apply_effective_settings(ctx: &mut RequestContext, stored: &crate::storage::R &stored.effective_tool_choice, ctx.original_request.tool_choice.is_some(), )); - ctx.tool_search_loaded_tools - .clone_from(&stored.tool_search_loaded_tools); } #[cfg(test)] @@ -241,7 +215,7 @@ mod tests { } #[tokio::test] - async fn execution_rehydration_prepares_function_only_blocking_private_request() { + async fn rehydration_remains_public_until_explicit_tool_search_preparation() { let exec_ctx = execution_context(ConversationStore::disabled(), ResponseStore::disabled()); let request: RequestPayload = serde_json::from_value(serde_json::json!({ "model": "test", @@ -256,16 +230,35 @@ mod tests { })) .expect("valid tool-search request"); - let ctx = rehydrate_for_execution(request, &exec_ctx) + let ctx = rehydrate_conversation(request, &exec_ctx) .await - .expect("blocking store:false search is valid after preparation"); + .expect("blocking store:false search rehydrates"); + + assert!(matches!( + ctx.enriched_request.tools.as_deref(), + Some([crate::types::tools::ResponsesTool::ToolSearch(search)]) + if search.execution == crate::types::tools::ToolSearchExecution::Client + )); + + let ctx = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) + .await + .expect("explicit handler preparation accepts the rehydrated request"); assert!( - ctx.tool_search_state - .as_ref() + ctx.tool_search() + .state() .is_some_and(crate::tool::ToolSearchState::is_active) ); - assert!(ctx.tool_search_private_request.is_some()); + let upstream = ctx + .request() + .enriched_request + .to_upstream_request(false) + .expect("prepared tool-search request lowers at the upstream boundary"); + assert!(matches!( + upstream.tools.as_deref(), + Some([crate::types::request_response::UpstreamTool::Function(function)]) + if function.name == "tool_search" + )); } #[tokio::test] @@ -291,9 +284,12 @@ mod tests { .expect("seed prior response"); let exec_ctx = execution_context(ConversationStore::disabled(), response_store); - let error = rehydrate_for_execution(request(None, Some("resp_search")), &exec_ctx) + let ctx = rehydrate_conversation(request(None, Some("resp_search")), &exec_ctx) + .await + .expect("orphan history remains a valid rehydrated public shape"); + let error = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) .await - .expect_err("orphan in stored public history must fail after rehydration"); + .expect_err("explicit preparation rejects orphan stored public history"); assert!( matches!(error, ExecutorError::Tool(ToolError::Config(ref message)) if message.contains("orphan")), @@ -361,15 +357,16 @@ mod tests { }])) .expect("valid new public search output"); - let mut ctx = rehydrate_conversation(continuation, &exec_ctx) + let ctx = rehydrate_conversation(continuation, &exec_ctx) .await .expect("stored public call rehydrates before new output"); - assert!(ctx.tool_search_state.is_none()); - prepare_tool_search(&mut ctx).expect("stored continuation derives valid tool-search state"); + let ctx = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) + .await + .expect("stored continuation derives valid tool-search state"); let state = ctx - .tool_search_state - .as_ref() + .tool_search() + .state() .expect("valid state was prepared after rehydration"); assert!(state.is_active()); assert_eq!(state.loaded_public_tools().len(), 1); @@ -377,13 +374,8 @@ mod tests { &state.loaded_public_tools()[0], crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather" )); - let private_input = serde_json::to_value( - &ctx.tool_search_private_request - .as_deref() - .expect("active state materializes a private inference request") - .input, - ) - .expect("prepared private history serializes"); + let private_input = + serde_json::to_value(&ctx.request().enriched_request.input).expect("prepared private history serializes"); assert_eq!(private_input[0]["call_id"], "call_search_stored"); assert_eq!(private_input[1]["call_id"], "call_search_stored"); } diff --git a/crates/agentic-server-core/src/executor/request.rs b/crates/agentic-server-core/src/executor/request.rs index 7e244f72..98852560 100644 --- a/crates/agentic-server-core/src/executor/request.rs +++ b/crates/agentic-server-core/src/executor/request.rs @@ -3,13 +3,14 @@ use std::time::Duration; use crate::config::{Config, default_database_url}; use crate::error::Error; +use crate::executor::error::ExecutorResult; use crate::executor::modes::{ConversationHandler, ResponseHandler}; use crate::storage::backend::redact_database_urls; use crate::storage::{ ConversationStore, ConversationVersion, DatabaseBackend, ResponseMetadata, ResponseStore, create_pool_with_schema_and_configs, }; -use crate::tool::{GatewayExecutor, GatewayExecutors, ToolSearchState}; +use crate::tool::{GatewayExecutor, GatewayExecutors, PreparedToolSearch, ToolRegistry}; use crate::types::io::InputItem; use crate::types::messages::GatewayToolMap; use crate::types::request_response::{RequestPayload, ResponsePayload}; @@ -28,16 +29,6 @@ pub struct RequestContext { /// Enriched request with rehydrated conversation history injected into `.input`. /// This is the request forwarded to the LLM. pub enriched_request: RequestPayload, - /// Pure request-scoped tool-search views prepared after full rehydration. - /// Public state remains separate from the private model request. - pub tool_search_state: Option, - /// Private inference request prepared once for active tool search. - /// Blocking and streaming execution consume this exact instance while - /// `enriched_request` remains the public representation. - pub tool_search_private_request: Option>, - /// Public definitions known to have been loaded before compaction removed - /// their search call/output pair. - pub tool_search_loaded_tools: Option>, /// Only the new input items submitted by the client this turn (used for persistence). pub new_input_items: Vec, /// Our generated response ID (uuid7 with "resp_" prefix). @@ -50,60 +41,85 @@ pub struct RequestContext { } impl RequestContext { + /// Construct generic response metadata for callers that do not need + /// tool-specific preparation. #[must_use] - pub(crate) fn inference_request(&self) -> &RequestPayload { - self.tool_search_private_request - .as_deref() - .unwrap_or(&self.enriched_request) + pub(crate) fn take_response_metadata(&mut self) -> ResponseMetadata { + ResponseMetadata { + model: std::mem::take(&mut self.enriched_request.model), + previous_response_id: self.original_request.previous_response_id.take(), + effective_tools: self.enriched_request.tools.take(), + tool_search_loaded_tools: None, + effective_tool_choice: self.enriched_request.tool_choice.take().unwrap_or_default(), + effective_instructions: self.enriched_request.instructions.take(), + } + } + + /// Inject our `response_id` and `conversation_id` into a `ResponsePayload` + /// received from the LLM (which carries the upstream's own IDs). + pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) { + payload.id.clone_from(&self.response_id); + payload.conversation_id.clone_from(&self.conversation_id); + payload + .previous_response_id + .clone_from(&self.original_request.previous_response_id); } +} + +/// Fully prepared executor state for one Responses turn. +/// +/// The generic request context stays tool-agnostic; tool-search preparation is +/// carried beside it and is unavailable outside the executor crate. +#[derive(Debug)] +pub(crate) struct PreparedTurn { + request: RequestContext, + tool_search: PreparedToolSearch, +} +impl PreparedTurn { #[must_use] - pub(crate) fn inference_request_mut(&mut self) -> &mut RequestPayload { - self.tool_search_private_request - .as_deref_mut() - .unwrap_or(&mut self.enriched_request) + pub(crate) const fn new(request: RequestContext, tool_search: PreparedToolSearch) -> Self { + Self { request, tool_search } } - /// Construct the effective public metadata shared by response and - /// conversation persistence. #[must_use] - pub(crate) fn response_metadata(&self) -> ResponseMetadata { - let active_search = self.tool_search_state.as_ref().filter(|state| state.is_active()); - ResponseMetadata { - model: self.enriched_request.model.clone(), - previous_response_id: self.original_request.previous_response_id.clone(), - effective_tools: active_search - .and_then(|state| state.public_effective_tools().map(<[_]>::to_vec)) - .or_else(|| self.enriched_request.tools.clone()), - tool_search_loaded_tools: active_search.map(|state| state.loaded_public_tools().to_vec()), - effective_tool_choice: self.enriched_request.tool_choice.clone().unwrap_or_default(), - effective_instructions: self.enriched_request.instructions.clone(), - } + pub(crate) const fn request(&self) -> &RequestContext { + &self.request + } + + pub(crate) const fn request_mut(&mut self) -> &mut RequestContext { + &mut self.request + } + + pub(crate) fn apply_tool_search_to_registry(&self, registry: &mut ToolRegistry) -> ExecutorResult<()> { + self.tool_search.apply_to_registry(registry)?; + Ok(()) } - /// Return the public effective declarations for an active tool-search - /// response envelope without request-scoped MCP credentials or discovery - /// state. `Some([])` distinguishes an active declaration-free replay from - /// an inactive request, so private upstream declarations can never pass - /// through unchanged. #[must_use] pub(crate) fn tool_search_response_tools(&self) -> Option> { - let state = self.tool_search_state.as_ref().filter(|state| state.is_active())?; - let mut tools = state.public_effective_tools().unwrap_or_default().to_vec(); - for tool in &mut tools { - tool.sanitize_for_persistence(); + self.tool_search.public_response_tools() + } + + #[must_use] + pub(crate) fn into_request(self) -> RequestContext { + self.request + } + + #[must_use] + pub(crate) fn take_response_metadata(&mut self) -> ResponseMetadata { + let public_metadata = self.tool_search.take_public_metadata(); + let mut metadata = self.request.take_response_metadata(); + if let Some((effective_tools, loaded_tools)) = public_metadata { + metadata.effective_tools = effective_tools; + metadata.tool_search_loaded_tools = Some(loaded_tools); } - Some(tools) + metadata } - /// Inject our `response_id` and `conversation_id` into a `ResponsePayload` - /// received from the LLM (which carries the upstream's own IDs). - pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) { - payload.id.clone_from(&self.response_id); - payload.conversation_id.clone_from(&self.conversation_id); - payload - .previous_response_id - .clone_from(&self.original_request.previous_response_id); + #[cfg(test)] + pub(crate) fn tool_search(&self) -> &PreparedToolSearch { + &self.tool_search } } @@ -273,75 +289,9 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use super::{ExecutionContext, RequestContext, database_open_error}; + use super::{ExecutionContext, database_open_error}; use crate::executor::{ConversationHandler, ResponseHandler}; use crate::storage::{ConversationStore, DatabaseBackend, ResponseStore, create_pool_with_schema}; - use crate::tool::ToolSearchState; - use crate::types::request_response::RequestPayload; - use crate::types::tools::{McpDiscoveredToolParam, ResponsesTool}; - - #[test] - fn tool_search_response_tools_restore_public_declarations_without_mcp_secrets() { - let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test", - "input": "find weather", - "parallel_tool_calls": false, - "tools": [ - { - "type": "tool_search", - "execution": "client", - "description": "Search tools", - "parameters": {"type": "object"} - }, - { - "type": "mcp", - "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "headers": {"Authorization": "Bearer header-secret"}, - "authorization": "field-secret", - "defer_loading": true - } - ] - })) - .expect("request shape"); - let ResponsesTool::Mcp(mcp) = &mut request.tools.as_mut().expect("tools")[1] else { - panic!("expected MCP declaration") - }; - mcp.discovered_tools.push(McpDiscoveredToolParam { - server_label: "weather".to_owned(), - tool_name: "forecast".to_owned(), - internal_name: "mcp__weather__forecast".to_owned(), - tool: serde_json::from_value(serde_json::json!({ - "name": "forecast", - "inputSchema": {"type": "object"} - })) - .expect("discovered MCP tool"), - }); - let state = ToolSearchState::build(&request).expect("tool-search state"); - let context = RequestContext { - original_request: request.clone(), - enriched_request: request, - tool_search_state: Some(state), - tool_search_private_request: None, - tool_search_loaded_tools: None, - new_input_items: Vec::new(), - response_id: "resp_test".to_owned(), - conversation_id: None, - conversation_version: None, - }; - - let tools = - serde_json::to_value(context.tool_search_response_tools().expect("active tools")).expect("tools serialize"); - assert_eq!(tools[1]["server_description"], "Weather tools"); - assert_eq!(tools[1]["defer_loading"], true); - assert!(tools[1].get("headers").is_none()); - assert!(tools[1].get("authorization").is_none()); - assert!(tools[1].get("_agentic_discovered_tools").is_none()); - for secret in ["header-secret", "field-secret", "mcp__weather__forecast"] { - assert!(!tools.to_string().contains(secret)); - } - } #[test] fn database_errors_are_actionable_without_exposing_credentials() { diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index a7a5cf39..a212df69 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -12,15 +12,15 @@ use crate::executor::gateway::{ }; 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::executor::request::{ExecutionContext, PreparedTurn, RequestContext}; use crate::tool::ToolRegistry; use crate::types::request_response::ResponsePayload; -use crate::utils::common::{deserialize_from_str, serialize_to_string, serialize_to_value}; +use crate::utils::common::{serialize_to_string, serialize_to_value}; const MAX_DEFERRED_STREAM_BYTES: usize = 256 * 1024; struct StreamEmitContext<'a> { - request: &'a RequestContext, + request: &'a PreparedTurn, registry: &'a ToolRegistry, sender: &'a tokio::sync::mpsc::UnboundedSender, accumulator: &'a mut GatewayStreamAccumulator, @@ -40,13 +40,12 @@ pub(super) async fn fetch_blocking_payload( ) -> ExecutorResult { let url = exec_ctx.responses_url(); // Non-streaming request: stream=false -> full JSON body -> from_json. - let upstream_request = ctx.inference_request().to_upstream_request(false)?; + let upstream_request = ctx.enriched_request.to_upstream_request(false)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let body = fetch_response_json(upstream_json, &url, &exec_ctx.client, auth).await?; - let mut raw: Value = deserialize_from_str(&body).map_err(ExecutorError::JsonError)?; - registry.normalize_blocking_response(&mut raw)?; - let acc = ResponseAccumulator::from_value(raw, ctx.conversation_id.as_deref())?; + let acc = ResponseAccumulator::from_json(&body, ctx.conversation_id.as_deref())? + .with_tool_types(registry.tool_type_map(), registry.withheld_function_names())?; let mut payload = acc.finalize( &ctx.enriched_request.model, ctx.original_request.previous_response_id.as_deref(), @@ -58,7 +57,7 @@ pub(super) async fn fetch_blocking_payload( } pub(super) async fn fetch_stream_payload( - ctx: &RequestContext, + ctx: &PreparedTurn, exec_ctx: &ExecutionContext, auth: Option<&str>, registry: &ToolRegistry, @@ -69,7 +68,7 @@ pub(super) async fn fetch_stream_payload( output_offset: usize, ) -> ExecutorResult { let url = exec_ctx.responses_url(); - let upstream_request = ctx.inference_request().to_upstream_request(true)?; + let upstream_request = ctx.request().enriched_request.to_upstream_request(true)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let mut line_stream = Box::pin(call_inference( upstream_json, @@ -78,9 +77,11 @@ pub(super) async fn fetch_stream_payload( auth.map(str::to_owned), exec_ctx.streaming_timeout, )); - let mut acc = ResponseAccumulator::new(ctx.response_id.clone(), ctx.conversation_id.clone()); - let mut function_sse = FunctionSseTranslator::new(registry.tool_type_map()) - .with_withheld_function_names(registry.withheld_function_names()); + let tool_types = registry.tool_type_map(); + let mut acc = ResponseAccumulator::new(ctx.request().response_id.clone(), ctx.request().conversation_id.clone()) + .with_tool_types(tool_types.clone(), registry.withheld_function_names())?; + let mut function_sse = + FunctionSseTranslator::new(tool_types).with_withheld_function_names(registry.withheld_function_names()); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); let mut deferred_bytes = 0; @@ -88,7 +89,7 @@ pub(super) async fn fetch_stream_payload( let line = line_result?; if stream.is_none() { if let Some(frame) = acc.process_sse_line(&line) { - log_upstream_failure(&frame, &ctx.response_id); + log_upstream_failure(&frame, &ctx.request().response_id); } continue; } @@ -96,7 +97,7 @@ pub(super) async fn fetch_stream_payload( let previous_defer_from_output_index = defer_from_output_index; defer_from_output_index = translation.defer_from_output_index.map(u64::from); for frame in &translation.frames { - log_upstream_failure(frame, &ctx.response_id); + log_upstream_failure(frame, &ctx.request().response_id); } if let Some((accumulator, sender)) = stream.as_mut() { let mut emit_ctx = StreamEmitContext { @@ -141,10 +142,13 @@ pub(super) async fn fetch_stream_payload( function_sse.finish()?; } acc.finish_stream(); + if let Some(error) = acc.take_processing_error() { + return Err(error); + } let mut payload = acc.finalize( - &ctx.enriched_request.model, - ctx.original_request.previous_response_id.as_deref(), - ctx.original_request.instructions.as_deref(), + &ctx.request().enriched_request.model, + ctx.request().original_request.previous_response_id.as_deref(), + ctx.request().original_request.instructions.as_deref(), ); if matches!(payload.status.as_str(), "error" | "failed" | "incomplete") { payload.output.retain(|item| { @@ -155,7 +159,7 @@ pub(super) async fn fetch_stream_payload( ) }); } - ctx.inject_ids(&mut payload); + ctx.request().inject_ids(&mut payload); Ok(StreamPayload { payload, deferred_events, @@ -192,7 +196,7 @@ fn log_upstream_failure(frame: &EventFrame, gateway_response_id: &str) { pub(super) fn emit_deferred_stream_events( deferred_events: Vec, - request: &RequestContext, + request: &PreparedTurn, registry: &ToolRegistry, accumulator: &mut GatewayStreamAccumulator, sender: &tokio::sync::mpsc::UnboundedSender, @@ -221,7 +225,7 @@ fn should_defer_stream_event(frame: &EventFrame, defer_from_output_index: Option } fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_>) -> ExecutorResult { - apply_context_response_ids(&mut frame.wire, emit_ctx.request); + apply_context_response_ids(&mut frame.wire, emit_ctx.request.request()); restore_public_tool_search_response_tools(&mut frame.wire, emit_ctx.request)?; emit_ctx.registry.restore_stream_event_wire(&mut frame.wire); let emitted = emit_ctx.accumulator.process_event(frame, emit_ctx.output_offset); @@ -231,7 +235,7 @@ fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_ Ok(emitted) } -fn restore_public_tool_search_response_tools(wire: &mut WireEvent, request: &RequestContext) -> ExecutorResult<()> { +fn restore_public_tool_search_response_tools(wire: &mut WireEvent, request: &PreparedTurn) -> ExecutorResult<()> { let Some(response) = wire.rest.get_mut("response").and_then(Value::as_object_mut) else { return Ok(()); }; @@ -340,7 +344,7 @@ mod tests { use crate::types::io::ResponsesInput; use crate::types::request_response::RequestPayload; - fn request_context() -> RequestContext { + fn request_context() -> PreparedTurn { let request = RequestPayload { model: "test".to_owned(), input: ResponsesInput::Text("hi".to_owned()), @@ -361,17 +365,15 @@ mod tests { cache_salt: None, context_management: None, }; - RequestContext { + let request = RequestContext { original_request: request.clone(), enriched_request: request, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: "resp_test".to_owned(), conversation_id: None, conversation_version: None, - } + }; + PreparedTurn::new(request, crate::tool::PreparedToolSearch::default()) } fn frame(output_index: u64, payload: Value) -> EventFrame { diff --git a/crates/agentic-server-core/src/lib.rs b/crates/agentic-server-core/src/lib.rs index 1981fb85..a64c73ba 100644 --- a/crates/agentic-server-core/src/lib.rs +++ b/crates/agentic-server-core/src/lib.rs @@ -16,7 +16,7 @@ pub use storage::{ }; pub use tool::{ CodexNamespaceHandler, FunctionHandler, GatewayExecutor, GatewayExecutorRegistration, McpServerEntry, ToolEntry, - ToolError, ToolHandler, ToolOutput, ToolRegistry, ToolType, WebSearchHandler, + ToolError, ToolHandler, ToolOutput, ToolRegistry, ToolSearchHandler, ToolType, WebSearchHandler, }; pub use types::{ AllowedTool, AllowedToolsMode, CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, diff --git a/crates/agentic-server-core/src/storage/conversation.rs b/crates/agentic-server-core/src/storage/conversation.rs index a95af9b5..43ef120e 100644 --- a/crates/agentic-server-core/src/storage/conversation.rs +++ b/crates/agentic-server-core/src/storage/conversation.rs @@ -3,7 +3,6 @@ use std::convert::TryFrom; use std::sync::Arc; -use super::backend::DatabaseBackend; use super::models::{conversation, item, response}; use super::pool::DbPool; use super::types::{ @@ -90,24 +89,7 @@ impl ConversationStore { /// Returns an error if a stored item is missing its sequence number or if the database query fails. pub async fn rehydrate_snapshot(&self, conversation_id: &str) -> StoreResult { let pool = self.pool()?; - let mut tx = pool.begin().await?; - if DatabaseBackend::from_connection(tx.as_mut()) == DatabaseBackend::Postgres { - sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") - .execute(&mut *tx) - .await?; - } - let rows = item::get_items_by_conversation_in_tx(&mut tx, conversation_id).await?; - let latest_item_id = rows.last().map(|row| row.id.as_str()); - let conversation_turns = response::get_conversation_turns_in_tx(&mut tx, conversation_id).await?; - let latest_response = latest_item_id.and_then(|latest_item_id| { - conversation_turns.into_iter().find(|response| { - response - .history_item_ids_vec() - .last() - .is_some_and(|item_id| item_id == latest_item_id) - }) - }); - tx.commit().await?; + let rows = item::get_items_by_conversation(pool, conversation_id).await?; let mut last_sequence = None; for row in &rows { @@ -120,10 +102,30 @@ impl ConversationStore { Ok(ConversationSnapshot { items: rows.into_iter().filter_map(|row| row.as_inout()).collect(), version: ConversationVersion::from_last_sequence(last_sequence), - latest_response_metadata: latest_response.and_then(|row| row.metadata_as()), }) } + /// Loads metadata from the item-bearing persisted turn at a captured version. + /// + /// # Errors + /// + /// Returns an error if either targeted database lookup fails. + pub async fn response_metadata_at_version( + &self, + conversation_id: &str, + version: ConversationVersion, + ) -> StoreResult> { + let ConversationVersion::LastSequence(sequence) = version else { + return Ok(None); + }; + let pool = self.pool()?; + let Some(item_id) = item::get_id_by_conversation_sequence(pool, conversation_id, sequence).await? else { + return Ok(None); + }; + let response = response::get_conversation_turn_for_item(pool, conversation_id, &item_id).await?; + Ok(response.and_then(|row| row.metadata_as())) + } + /// Persists conversation turn with new items and response metadata. /// /// Creates items in the conversation and stores the associated response record. diff --git a/crates/agentic-server-core/src/storage/models/item.rs b/crates/agentic-server-core/src/storage/models/item.rs index d0d7c8e1..e9fe4997 100644 --- a/crates/agentic-server-core/src/storage/models/item.rs +++ b/crates/agentic-server-core/src/storage/models/item.rs @@ -232,14 +232,19 @@ pub async fn get_items_by_conversation(pool: &DbPool, conversation_id: &str) -> .await } -/// Get conversation items in sequence order within an existing transaction. +/// Get a conversation item ID by its sequence number. /// /// # Errors /// Returns `DbResult::Err` if the database query fails. -pub async fn get_items_by_conversation_in_tx(tx: &mut DbTransaction<'_>, conversation_id: &str) -> DbResult> { - sqlx::query_as::<_, Item>("SELECT * FROM items WHERE conversation_id = $1 ORDER BY seq ASC") +pub async fn get_id_by_conversation_sequence( + pool: &DbPool, + conversation_id: &str, + sequence: i64, +) -> DbResult> { + sqlx::query_scalar("SELECT id FROM items WHERE conversation_id = $1 AND seq = $2") .bind(conversation_id) - .fetch_all(&mut **tx) + .bind(sequence) + .fetch_optional(pool) .await } diff --git a/crates/agentic-server-core/src/storage/models/response.rs b/crates/agentic-server-core/src/storage/models/response.rs index 801f5d7f..83d759f0 100644 --- a/crates/agentic-server-core/src/storage/models/response.rs +++ b/crates/agentic-server-core/src/storage/models/response.rs @@ -67,21 +67,31 @@ pub async fn get(pool: &DbPool, id: &str) -> DbResult> { .await } -/// Get responses written by conversation turns within an existing transaction. +/// Get the conversation turn that persisted a specific item. /// /// Responses branched through `previous_response_id` retain the originating /// conversation ID for response-chain rehydration, but are not conversation turns. /// /// # Errors /// Returns `DbResult::Err` if the database query fails. -pub async fn get_conversation_turns_in_tx( - tx: &mut DbTransaction<'_>, +pub async fn get_conversation_turn_for_item( + pool: &DbPool, conversation_id: &str, -) -> DbResult> { - sqlx::query_as::<_, Response>("SELECT * FROM responses WHERE conversation_id = $1 AND previous_response_id IS NULL") - .bind(conversation_id) - .fetch_all(&mut **tx) - .await + item_id: &str, +) -> DbResult> { + let escaped_item_id = item_id.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + let history_suffix = format!("%\"{escaped_item_id}\"]"); + sqlx::query_as::<_, Response>( + "SELECT * FROM responses \ + WHERE conversation_id = $1 \ + AND previous_response_id IS NULL \ + AND history_item_ids LIKE $2 ESCAPE '!' \ + LIMIT 1", + ) + .bind(conversation_id) + .bind(history_suffix) + .fetch_optional(pool) + .await } impl Response { diff --git a/crates/agentic-server-core/src/storage/types/conversation.rs b/crates/agentic-server-core/src/storage/types/conversation.rs index 08419868..71c0f590 100644 --- a/crates/agentic-server-core/src/storage/types/conversation.rs +++ b/crates/agentic-server-core/src/storage/types/conversation.rs @@ -2,7 +2,6 @@ use super::super::models::Conversation as StorageDbConversation; use super::item::InOutItem; -use super::response::ResponseMetadata; /// Version of a conversation's stored item history. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -29,11 +28,6 @@ pub struct ConversationSnapshot { pub items: Vec, /// Version derived from the last stored item sequence. pub version: ConversationVersion, - /// Effective public settings from the latest item-bearing persisted turn. - /// - /// Item-free writes do not advance [`ConversationVersion`] and cannot be - /// correlated to a distinct conversation checkpoint without a schema pointer. - pub latest_response_metadata: Option, } /// Domain entity for a stored conversation. diff --git a/crates/agentic-server-core/src/storage/types/item.rs b/crates/agentic-server-core/src/storage/types/item.rs index c4a3b664..5159d4ea 100644 --- a/crates/agentic-server-core/src/storage/types/item.rs +++ b/crates/agentic-server-core/src/storage/types/item.rs @@ -80,20 +80,12 @@ impl TryFrom<&InOutItem> for String { fn try_from(item: &InOutItem) -> Result { let (mut value, kind) = match item { - InOutItem::Input(input) => { - let mut persisted = input.clone(); - if let InputItem::ToolSearchOutput(output) = &mut persisted { - for tool in &mut output.tools { - tool.sanitize_for_persistence(); - } - } - ( - serialize_to_value(&persisted).map_err(StorageError::Serialization)?, - ItemKind::Input, - ) - } + InOutItem::Input(input) => ( + serde_json::to_value(input).map_err(StorageError::Serialization)?, + ItemKind::Input, + ), InOutItem::Output(output) => ( - serialize_to_value(output).map_err(StorageError::Serialization)?, + serde_json::to_value(output).map_err(StorageError::Serialization)?, ItemKind::Output, ), }; @@ -168,33 +160,6 @@ mod tests { assert!(json.contains("test")); } - #[test] - fn tool_search_output_persistence_sanitizes_mcp_credentials_without_changing_public_type() { - let input: InputItem = serde_json::from_value(serde_json::json!({ - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": [{ - "type": "mcp", - "server_label": "private-server", - "server_description": "Private server", - "server_url": "https://mcp.example.test/mcp", - "headers": {"X-API-Key": "secret"}, - "authorization": "bearer-secret", - "defer_loading": true - }] - })) - .expect("valid public tool-search output"); - - let stored = String::try_from(&InOutItem::Input(input)).expect("serialize stored item"); - let value: Value = serde_json::from_str(&stored).expect("stored JSON"); - - assert_eq!(value["type"], "tool_search_output"); - assert_eq!(value["tools"][0]["server_label"], "private-server"); - assert!(value["tools"][0].get("headers").is_none()); - assert!(value["tools"][0].get("authorization").is_none()); - assert!(value["tools"][0].get("_agentic_discovered_tools").is_none()); - } - #[test] fn test_into_input_items_converts_output_messages() { let mut output = OutputMessage::new("out1", MessageStatus::Completed); diff --git a/crates/agentic-server-core/src/storage/types/response.rs b/crates/agentic-server-core/src/storage/types/response.rs index fc8bc428..b5db56c1 100644 --- a/crates/agentic-server-core/src/storage/types/response.rs +++ b/crates/agentic-server-core/src/storage/types/response.rs @@ -8,7 +8,7 @@ use super::super::models::Response as StorageDbResponse; use super::errors::StorageError; use crate::types::io::ToolChoice; use crate::types::tools::ResponsesTool; -use crate::utils::common::{serialize_to_string, serialize_to_value}; +use crate::utils::common::serialize_to_string; /// Response metadata with effective configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -25,15 +25,6 @@ pub struct ResponseMetadata { pub effective_instructions: Option, } -impl PartialEq for ResponseMetadata { - fn eq(&self, other: &Self) -> bool { - // Tool wire models intentionally do not expose a broad `PartialEq` - // contract. Metadata equality follows their complete serialized public - // representation so `ConversationSnapshot` retains its existing API. - serialize_to_value(self).ok() == serialize_to_value(other).ok() - } -} - /// Domain entity for a stored LLM response. #[derive(Debug, Clone)] pub struct ResponseData { @@ -152,9 +143,7 @@ mod tests { let mut tool = serde_json::from_value(serde_json::json!({ "type": "mcp", "server_label": "counter", - "server_description": "Counter tools", "server_url": "https://mcp.example.com/mcp", - "defer_loading": true, "headers": {"X-API-Key": "secret"}, "authorization": "bearer-secret", "require_approval": "never" @@ -176,8 +165,8 @@ mod tests { .expect("discovered MCP tool"), }); let metadata = ResponseMetadata { - effective_tools: Some(vec![tool.clone()]), - tool_search_loaded_tools: Some(vec![tool]), + effective_tools: Some(vec![tool]), + tool_search_loaded_tools: None, ..ResponseMetadata::default() }; @@ -198,16 +187,6 @@ mod tests { assert!(tool.headers.is_none()); assert!(tool.authorization.is_none()); - assert_eq!(tool.server_description.as_deref(), Some("Counter tools")); - assert_eq!(tool.defer_loading, Some(true)); - - let loaded = persisted.tool_search_loaded_tools.expect("persisted loaded tools"); - let ResponsesTool::Mcp(loaded) = &loaded[0] else { - panic!("expected loaded MCP tool"); - }; - assert!(loaded.headers.is_none()); - assert!(loaded.authorization.is_none()); - assert!(loaded.discovered_tools.is_empty()); } #[test] diff --git a/crates/agentic-server-core/src/tool/codex.rs b/crates/agentic-server-core/src/tool/codex.rs index 6d7afd0c..aca938df 100644 --- a/crates/agentic-server-core/src/tool/codex.rs +++ b/crates/agentic-server-core/src/tool/codex.rs @@ -9,11 +9,38 @@ use crate::types::tools::{CodexNamespaceMember, CodexNamespaceToolParam, NonEmpt use crate::utils::common::serialize_to_value_or_custom_default; use super::handler::{ToolError, ToolHandler}; -use super::names::{model_visible_namespace_member_name, validate_model_visible_declared_names}; use super::registry::{ToolEntry, ToolType}; -#[cfg(test)] -use super::names::{MAX_MODEL_VISIBLE_TOOL_NAME_LEN, MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}; +// Upstream Responses-compatible backends only see flat function names. Prefix +// flattened Codex namespace members so generated names are recognizable, +// unlikely to collide with user functions, and can be restored to +// `{ namespace, name }` on the way back to the client. +pub const MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX: &str = "agentic_ns__"; +pub const MAX_MODEL_VISIBLE_TOOL_NAME_LEN: usize = 64; + +const HASHED_NAMESPACE_MEMBER_SUFFIX_LEN: usize = 18; + +fn stable_name_hash(value: &str) -> u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + value.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME) + }) +} + +#[must_use] +pub fn model_visible_namespace_member_name(namespace: &str, member: &str) -> String { + let full_name = format!("{MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}{namespace}__{member}"); + if full_name.chars().count() <= MAX_MODEL_VISIBLE_TOOL_NAME_LEN { + return full_name; + } + + let hash = stable_name_hash(&full_name); + let readable_len = MAX_MODEL_VISIBLE_TOOL_NAME_LEN - HASHED_NAMESPACE_MEMBER_SUFFIX_LEN; + let readable_prefix = full_name.chars().take(readable_len).collect::(); + format!("{readable_prefix}__{hash:016x}") +} /// Registers one `ToolEntry` per `Function` member of `p`, keyed by the /// member's already-flattened, model-visible name — callers must resolve @@ -93,10 +120,41 @@ impl NamespaceMap { #[derive(Default)] struct NamespaceMapBuilder { + top_level_registry_keys: HashMap, map: NamespaceMap, } impl NamespaceMapBuilder { + fn new(top_level_registry_keys: HashMap) -> Self { + Self { + top_level_registry_keys, + ..Self::default() + } + } + + fn validate_and_record_flat_member( + &mut self, + namespace_name: &str, + member_name: &str, + ) -> Result { + let flat_name = model_visible_namespace_member_name(namespace_name, member_name); + if let Some(tool_kind) = self.top_level_registry_keys.get(&flat_name) { + return Err(ToolError::Config(format!( + "codex namespace member {namespace_name}.{member_name} generates name {flat_name}, which collides with a declared {}", + tool_kind.description() + ))); + } + if let Some(existing) = self.map.calls.get(&flat_name) { + if existing.member.namespace != namespace_name || existing.member.name != member_name { + return Err(ToolError::Config(format!( + "codex namespace member {namespace_name}.{member_name} collides with {}.{} at generated name {flat_name}", + existing.member.namespace, existing.member.name + ))); + } + } + Ok(self.record_flat_member_with_flat_name(namespace_name, member_name, flat_name)) + } + fn record_flat_member_with_flat_name( &mut self, namespace_name: &str, @@ -165,21 +223,14 @@ impl CodexNamespaceHandler { /// collides with another declared function-call tool or with another /// namespace member. pub fn resolve_namespace_members(&self, tools: &[ResponsesTool]) -> Result, ToolError> { - validate_model_visible_declared_names(tools)?; - Ok(Self::resolve_namespace_members_after_validation(tools)) - } - - /// Rewrite namespace members after the shared declaration-name validation - /// has already succeeded at the caller's request boundary. - pub(crate) fn resolve_namespace_members_after_validation(tools: &[ResponsesTool]) -> Vec { - let mut builder = NamespaceMapBuilder::default(); + let mut builder = NamespaceMapBuilder::new(typed_top_level_registry_keys(tools)); tools .iter() .map(|tool| match tool { ResponsesTool::Namespace(namespace) => { - ResponsesTool::Namespace(rename_namespace_members(namespace, &mut builder)) + rename_namespace_members(namespace, &mut builder).map(ResponsesTool::Namespace) } - other => other.clone(), + other => Ok(other.clone()), }) .collect() } @@ -209,7 +260,19 @@ impl CodexNamespaceHandler { /// collides with another declared function-call tool or with another /// namespace member. pub fn validate_namespace_collisions(&self, tools: Option<&[ResponsesTool]>) -> Result<(), ToolError> { - tools.map_or(Ok(()), validate_model_visible_declared_names) + let Some(tools) = tools else { + return Ok(()); + }; + let mut builder = NamespaceMapBuilder::new(typed_top_level_registry_keys(tools)); + for tool in tools { + let ResponsesTool::Namespace(namespace) = tool else { + continue; + }; + for member_name in typed_function_member_names(namespace) { + builder.validate_and_record_flat_member(&namespace.name, &member_name)?; + } + } + Ok(()) } /// Resolves the request's `tool_choice` (defaulting to `ToolChoice::Auto` @@ -318,11 +381,10 @@ fn namespace_map_from_tools(tools: Option<&[ResponsesTool]>) -> Result CodexNamespaceToolParam { +) -> Result { let function_member_names = typed_function_member_names(namespace); if function_member_names.is_empty() { tracing::debug!( namespace = %namespace.name, "namespace tool has no function members to rename for upstream" ); - return namespace.clone(); + return Ok(namespace.clone()); } let tools = namespace .tools .iter() .map(|member| { let CodexNamespaceMember::Function(function) = member else { - return member.clone(); + return Ok(member.clone()); }; - let flat_name_text = model_visible_namespace_member_name(&namespace.name, function.name.as_str()); - builder.record_flat_member_with_flat_name(&namespace.name, function.name.as_str(), flat_name_text.clone()); + let flat_name_text = builder.validate_and_record_flat_member(&namespace.name, function.name.as_str())?; let flat_name = NonEmptyToolName::try_from(flat_name_text.clone()) .expect("generated namespace member names include a non-empty prefix"); tracing::debug!( @@ -382,14 +448,34 @@ fn rename_namespace_members( ); let mut function = function.clone(); function.name = flat_name; - CodexNamespaceMember::Function(function) + Ok(CodexNamespaceMember::Function(function)) }) - .collect(); + .collect::, ToolError>>()?; - CodexNamespaceToolParam { + Ok(CodexNamespaceToolParam { tools, ..namespace.clone() - } + }) +} + +fn typed_top_level_registry_keys(tools: &[ResponsesTool]) -> HashMap { + tools + .iter() + .filter_map(|tool| { + let registry_key = match tool { + ResponsesTool::Function(function) => function.name.as_str().to_owned(), + ResponsesTool::WebSearch(_) => "web_search".to_owned(), + ResponsesTool::FileSearch(_) => "file_search".to_owned(), + ResponsesTool::CodeInterpreter(_) => "code_interpreter".to_owned(), + ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::Namespace(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => return None, + }; + tool.tool_type().map(|tool_type| (registry_key, tool_type)) + }) + .collect() } fn typed_function_member_names(namespace: &CodexNamespaceToolParam) -> Vec { @@ -449,6 +535,10 @@ fn rewrite_tool_choice_with_map(choice: &ToolChoice, map: &NamespaceMap) -> Tool fn restore_response_value_with_map(value: &mut Value, map: &NamespaceMap) -> bool { let mut changed = false; + if let Some(object) = value.as_object_mut() { + changed |= restore_response_metadata_with_map(object, map); + } + if let Some(item) = value.as_object_mut().and_then(|object| object.get_mut("item")) { changed |= restore_call_value_with_map(item, map); } @@ -499,8 +589,35 @@ fn restore_call_value_with_map(value: &mut Value, map: &NamespaceMap) -> bool { true } +fn restore_response_metadata_with_map(object: &mut Map, map: &NamespaceMap) -> bool { + object + .get_mut("tool_choice") + .is_some_and(|choice| restore_tool_choice_with_map(choice, map)) +} + +fn restore_tool_choice_with_map(choice: &mut Value, map: &NamespaceMap) -> bool { + let Some(object) = choice.as_object_mut() else { + return false; + }; + if object.get("type").and_then(Value::as_str) != Some("function") + || object.get("namespace").and_then(Value::as_str).is_some() + { + return false; + } + let Some(mapping) = object + .get("name") + .and_then(Value::as_str) + .and_then(|name| map.mapping_for_call(name)) + else { + return false; + }; + object.insert("namespace".to_owned(), Value::String(mapping.member.namespace.clone())); + object.insert("name".to_owned(), Value::String(mapping.member.name.clone())); + true +} + fn restore_response_map_with_map(object: &mut Map, map: &NamespaceMap) -> bool { - let mut changed = false; + let mut changed = restore_response_metadata_with_map(object, map); if let Some(item) = object.get_mut("item") { changed |= restore_call_value_with_map(item, map); } @@ -792,7 +909,7 @@ mod tests { #[cfg(debug_assertions)] #[should_panic(expected = "namespace collisions must be validated before recording namespace members")] fn namespace_map_builder_debug_asserts_when_member_collision_validation_is_skipped() { - let mut builder = NamespaceMapBuilder::default(); + let mut builder = NamespaceMapBuilder::new(HashMap::new()); assert_eq!( builder.record_flat_member_with_flat_name("a__b", "c", "agentic_ns__a__b__c".to_owned()), @@ -889,4 +1006,37 @@ mod tests { assert_eq!(value["item"]["name"], "add_numbers"); assert_eq!(value["item"]["arguments"], "{\"numbers\":[8,0]}"); } + + #[test] + fn response_lifecycle_metadata_restores_public_namespace_tool_choice() { + let tools: Vec = serde_json::from_value(serde_json::json!([{ + "type": "namespace", + "name": "travel", + "tools": [{"type": "function", "name": "get_timezone"}] + }])) + .unwrap(); + let map = CodexNamespaceHandler + .build_namespace_map(Some(&tools)) + .expect("valid namespace map"); + let mut wire = WireEvent::new("response.created"); + wire.rest.insert( + "response".to_owned(), + serde_json::json!({ + "tool_choice": { + "type": "function", + "name": "agentic_ns__travel__get_timezone" + } + }), + ); + + assert!(CodexNamespaceHandler.restore_response_wire(&mut wire, map.as_ref())); + assert_eq!( + wire.rest["response"]["tool_choice"], + serde_json::json!({ + "type": "function", + "namespace": "travel", + "name": "get_timezone" + }) + ); + } } diff --git a/crates/agentic-server-core/src/tool/executors.rs b/crates/agentic-server-core/src/tool/executors.rs index 0cbf2f8d..7db9ffaa 100644 --- a/crates/agentic-server-core/src/tool/executors.rs +++ b/crates/agentic-server-core/src/tool/executors.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::mcp::handler::McpServerToolSet; -use super::mcp::pool::validate_request_server_url_with_allowed_hosts; use super::mcp::{McpClientPool, McpDiscoveredHandler, McpHandler}; use super::registry::ToolType; use super::web_search::WebSearchHandler; @@ -20,26 +19,6 @@ pub enum GatewayExecutorRegistration { }, } -/// Classifies MCP registry-build failures without weakening ordinary request -/// validation. Only a rejected request-declared transport URL can be converted -/// to a public discovery-failure item after tool search proves that definition -/// was loaded; all other configuration errors remain strict request errors. -#[derive(Debug, thiserror::Error)] -pub(crate) enum McpServerToolsError { - #[error(transparent)] - Tool(#[from] ToolError), - #[error(transparent)] - RequestServerUrl(ToolError), -} - -impl McpServerToolsError { - pub(crate) fn into_tool_error(self) -> ToolError { - match self { - Self::Tool(error) | Self::RequestServerUrl(error) => error, - } - } -} - impl From> for GatewayExecutorRegistration where T: GatewayExecutor, @@ -162,11 +141,7 @@ impl GatewayExecutors { /// Returns a configuration error for an invalid declaration or an empty /// allowed tool set, and an execution error when the server cannot connect. pub async fn mcp_handler(&mut self, param: &McpToolParam) -> Result, ToolError> { - Ok(self - .mcp_server_tools(param) - .await - .map_err(McpServerToolsError::into_tool_error)? - .discovered_handlers) + Ok(self.mcp_server_tools(param).await?.discovered_handlers) } /// Returns the request-scoped tools and public discovery item for one MCP server. @@ -175,13 +150,12 @@ impl GatewayExecutors { /// /// Returns a configuration error for an invalid declaration or an empty /// allowed tool set, and an execution error when the server cannot connect. - pub(crate) async fn mcp_server_tools( - &mut self, - param: &McpToolParam, - ) -> Result { + pub(crate) async fn mcp_server_tools(&mut self, param: &McpToolParam) -> Result { let server_label = param.server_label.trim(); if server_label.is_empty() { - return Err(ToolError::Config("MCP declaration requires a non-empty server_label".to_owned()).into()); + return Err(ToolError::Config( + "MCP declaration requires a non-empty server_label".to_owned(), + )); } let configured_handlers = self.mcp.get(server_label); let configured_server = self.mcp_configs.contains_key(server_label); @@ -189,8 +163,7 @@ impl GatewayExecutors { if (configured_server || configured_handlers.is_some()) && param.server_url.is_some() { return Err(ToolError::Config(format!( "MCP server '{server_label}' is configured by the gateway; omit server_url from the request" - )) - .into()); + ))); } if let Some(configured_handlers) = configured_handlers { let discovered_handlers = require_non_empty_mcp_handlers( @@ -205,7 +178,9 @@ impl GatewayExecutors { if configured_server { let Some(entry) = self.mcp_configs.get(server_label).cloned() else { - return Err(ToolError::Config(format!("configured MCP server '{server_label}' is missing")).into()); + return Err(ToolError::Config(format!( + "configured MCP server '{server_label}' is missing" + ))); }; let cached_client = self.mcp_clients.read().await.get(server_label).cloned(); let client = if let Some(client) = cached_client { @@ -219,8 +194,7 @@ impl GatewayExecutors { "configured MCP server '{server_label}' failed to connect: {}", pool.connection_error(server_label) .unwrap_or("unknown connection error") - )) - .into()); + ))); }; self.mcp_clients .write() @@ -251,22 +225,16 @@ impl GatewayExecutors { )); } - validate_request_declared_mcp_transport(param, &self.mcp_allowed_hosts)?; - let pool = McpClientPool::from_params_with_allowed_hosts(std::slice::from_ref(param), &self.mcp_allowed_hosts).await; let Some(client) = pool.get(server_label).cloned() else { return Err(pool.connection_error(server_label).map_or_else( || { - McpServerToolsError::from(ToolError::Config(format!( + ToolError::Config(format!( "MCP server '{server_label}' has no valid request-declared configuration" - ))) - }, - |error| { - McpServerToolsError::from(ToolError::Execution(format!( - "MCP server '{server_label}' failed to connect: {error}" - ))) + )) }, + |error| ToolError::Execution(format!("MCP server '{server_label}' failed to connect: {error}")), )); }; let tool_set = McpHandler::discover_tools(server_label, client, param.allowed_tools.as_deref()).await?; @@ -304,25 +272,6 @@ fn require_non_empty_mcp_handlers( Ok(handlers) } -fn validate_request_declared_mcp_transport( - param: &McpToolParam, - allowed_hosts: &[String], -) -> Result<(), McpServerToolsError> { - if param - .server_url - .as_deref() - .is_none_or(|url| validate_request_server_url_with_allowed_hosts(url, allowed_hosts).is_ok()) - { - return Ok(()); - } - - let server_label = param.server_label.trim(); - tracing::warn!(server_label, "MCP tool param server_url rejected"); - Err(McpServerToolsError::RequestServerUrl(ToolError::Config(format!( - "MCP server '{server_label}' has no valid request-declared configuration" - )))) -} - fn validate_mcp_execution_options(param: &McpToolParam, configured_server: bool) -> Result<(), ToolError> { if param.connector_id.is_some() { return Err(ToolError::Config( diff --git a/crates/agentic-server-core/src/tool/handler.rs b/crates/agentic-server-core/src/tool/handler.rs index 4ec6953f..99f99dfb 100644 --- a/crates/agentic-server-core/src/tool/handler.rs +++ b/crates/agentic-server-core/src/tool/handler.rs @@ -17,6 +17,10 @@ pub enum ToolError { Execution(String), #[error("invalid tool config: {0}")] Config(String), + #[error("upstream returned an invalid tool-search call")] + InvalidUpstreamToolSearch, + #[error("upstream returned a call for a function that has not been loaded")] + UpstreamWithheldFunctionCall, } /// Trait implemented by every tool type — client-owned and gateway-owned alike. @@ -45,8 +49,9 @@ pub trait ToolHandler: Send + Sync { /// Extension of [`ToolHandler`] for tool types that are executed by the gateway. /// /// Only gateway-owned tools (`Mcp`, `WebSearch`, `FileSearch`, `CodeInterpreter`) -/// implement this trait. Client-owned tools (`Function`) do not — the type system -/// makes it impossible to call `execute()` on them. +/// implement this trait. Client-owned tools (`Function`, `ToolSearch`, `Custom`, +/// `CodexNamespace`) do not — the type system makes it impossible to call +/// `execute()` on them. /// /// ## Note on `async fn` in traits /// diff --git a/crates/agentic-server-core/src/tool/mcp/handler.rs b/crates/agentic-server-core/src/tool/mcp/handler.rs index 7fb7e35f..bfc2e941 100644 --- a/crates/agentic-server-core/src/tool/mcp/handler.rs +++ b/crates/agentic-server-core/src/tool/mcp/handler.rs @@ -55,19 +55,6 @@ impl McpToolMap { .values() .any(|tool_ref| tool_ref.server_label == server_label) } - - pub(crate) fn resolves_call_before_load( - &self, - load_positions: &HashMap, - call_positions: &HashMap, - ) -> bool { - self.calls.iter().any(|(name, tool_ref)| { - load_positions - .get(&tool_ref.server_label) - .zip(call_positions.get(name)) - .is_some_and(|(load, call)| call < load) - }) - } } #[must_use] @@ -241,10 +228,9 @@ impl McpHandler { } #[must_use] - pub(crate) fn failed_list_tools_item(server_label: &str, _error: &ToolError) -> McpListTools { - tracing::warn!(server_label, "MCP server connection or tools/list failed"); + pub(crate) fn failed_list_tools_item(server_label: &str, error: &ToolError) -> McpListTools { let mut item = McpListTools::new(uuid7_str("mcpl_"), server_label, Vec::new()); - item.error = Some(format!("MCP server '{server_label}' failed to connect or list tools")); + item.error = Some(error.to_string()); item } @@ -606,30 +592,6 @@ mod tests { assert!(error.to_string().contains("timed out during tools/list")); } - #[test] - fn public_list_tools_failure_redacts_transport_and_request_secrets() { - let error = ToolError::Execution( - "failed https://url-user:url-password@mcp.example.test/private?token=query-secret \ - Authorization: Bearer authorization-secret X-Private-Token: header-secret" - .to_owned(), - ); - - let item = McpHandler::failed_list_tools_item("weather", &error); - let public_error = item.error.expect("public list failure"); - - assert!(public_error.contains("weather")); - for secret in [ - "mcp.example.test", - "url-user", - "url-password", - "query-secret", - "authorization-secret", - "header-secret", - ] { - assert!(!public_error.contains(secret), "public error leaked {secret}"); - } - } - #[test] fn mcp_tool_arguments_require_valid_json_object() { assert_eq!( diff --git a/crates/agentic-server-core/src/tool/mcp/pool.rs b/crates/agentic-server-core/src/tool/mcp/pool.rs index 4beb220b..de273d4e 100644 --- a/crates/agentic-server-core/src/tool/mcp/pool.rs +++ b/crates/agentic-server-core/src/tool/mcp/pool.rs @@ -90,14 +90,19 @@ impl McpClientPool { } => McpClient::connect_stdio(&command, &args, env.as_ref(), cwd.as_deref()).await, }; - if let Ok(client) = result { - clients.insert(server_label, Arc::new(client)); - } else { - tracing::warn!( - server_label = %server_label, - "failed to connect MCP server from config" - ); - connection_errors.insert(server_label, "MCP transport connection failed".to_owned()); + match result { + Ok(client) => { + clients.insert(server_label, Arc::new(client)); + } + Err(error) => { + let error_message = error.to_string(); + tracing::warn!( + server_label = %server_label, + error = %error_message, + "failed to connect MCP server from config" + ); + connection_errors.insert(server_label, error_message); + } } } @@ -125,9 +130,12 @@ fn server_entry_from_param(param: &McpToolParam, allowed_hosts: &[String]) -> Op }; if let Some(url) = clean_string(param.server_url.as_deref()) { - let Ok(url) = validate_request_server_url_with_allowed_hosts(&url, allowed_hosts) else { - tracing::warn!(server_label, "MCP tool param server_url rejected"); - return None; + let url = match validate_request_server_url_with_allowed_hosts(&url, allowed_hosts) { + Ok(url) => url, + Err(reason) => { + tracing::warn!(server_label, url, reason, "MCP tool param server_url rejected"); + return None; + } }; return Some(( @@ -153,10 +161,7 @@ fn request_headers(param: &McpToolParam) -> Option> { (!headers.is_empty()).then_some(headers) } -pub(crate) fn validate_request_server_url_with_allowed_hosts( - value: &str, - allowed_hosts: &[String], -) -> Result { +fn validate_request_server_url_with_allowed_hosts(value: &str, allowed_hosts: &[String]) -> Result { let url = Url::parse(value).map_err(|error| format!("invalid URL: {error}"))?; match url.scheme() { "http" | "https" => {} diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index 87611eb3..d98b4d08 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -9,19 +9,18 @@ pub mod executors; pub mod function; pub mod handler; pub mod mcp; -mod names; pub mod normalize; pub mod registry; -pub mod search; +pub mod tool_search; pub mod web_search; -pub use codex::{CodexNamespaceHandler, NamespaceMap}; +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}; pub use mcp::{McpClient, McpClientPool, McpDiscoveredHandler, McpError, McpHandler, McpOperation, McpServerEntry}; -pub use names::model_visible_namespace_member_name; pub use registry::{GatewayDispatchResult, ToolEntry, ToolRegistry, ToolType}; -pub use search::ToolSearchState; +pub(crate) use tool_search::PreparedToolSearch; +pub use tool_search::{ToolSearchHandler, ToolSearchState}; pub use web_search::WebSearchHandler; diff --git a/crates/agentic-server-core/src/tool/names.rs b/crates/agentic-server-core/src/tool/names.rs deleted file mode 100644 index 13fd7ed5..00000000 --- a/crates/agentic-server-core/src/tool/names.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::collections::HashMap; -use std::collections::hash_map::Entry; - -use crate::types::tools::{CodexNamespaceMember, ResponsesTool}; - -use super::ToolError; - -pub const MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX: &str = "agentic_ns__"; -pub const MAX_MODEL_VISIBLE_TOOL_NAME_LEN: usize = 64; - -const HASHED_NAMESPACE_MEMBER_SUFFIX_LEN: usize = 18; - -fn stable_name_hash(value: &str) -> u64 { - const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; - const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; - - value.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| { - (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME) - }) -} - -#[must_use] -pub fn model_visible_namespace_member_name(namespace: &str, member: &str) -> String { - let full_name = format!("{MODEL_VISIBLE_NAMESPACE_MEMBER_PREFIX}{namespace}__{member}"); - if full_name.chars().count() <= MAX_MODEL_VISIBLE_TOOL_NAME_LEN { - return full_name; - } - - let hash = stable_name_hash(&full_name); - let readable_len = MAX_MODEL_VISIBLE_TOOL_NAME_LEN - HASHED_NAMESPACE_MEMBER_SUFFIX_LEN; - let readable_prefix = full_name.chars().take(readable_len).collect::(); - format!("{readable_prefix}__{hash:016x}") -} - -enum DeclaredNameOrigin<'a> { - TopLevel { description: &'static str }, - NamespaceMember { namespace: &'a str, member: &'a str }, -} - -impl DeclaredNameOrigin<'_> { - fn description(&self) -> String { - match self { - Self::TopLevel { description } => (*description).to_owned(), - Self::NamespaceMember { namespace, member } => { - format!("Codex namespace member {namespace}.{member}") - } - } - } -} - -/// Validate the exact names that public declarations expose to the model. -/// -/// This pass is intentionally declaration-only and performs no MCP discovery. -/// Discovered MCP member collisions remain a post-`tools/list` registry check. -/// -/// # Errors -/// -/// Returns [`ToolError::Config`] when function, custom, built-in, or normalized -/// namespace-member declarations resolve to the same model-visible name. -pub(crate) fn validate_model_visible_declared_names(tools: &[ResponsesTool]) -> Result<(), ToolError> { - let mut names = HashMap::new(); - for tool in tools { - match tool { - ResponsesTool::Function(function) => { - record_name( - &mut names, - function.name.as_str(), - DeclaredNameOrigin::TopLevel { - description: "function tool", - }, - )?; - } - ResponsesTool::Custom(custom) => { - record_name( - &mut names, - custom.name.as_str(), - DeclaredNameOrigin::TopLevel { - description: "custom tool", - }, - )?; - } - ResponsesTool::WebSearch(_) => { - record_name( - &mut names, - "web_search", - DeclaredNameOrigin::TopLevel { - description: "web search tool", - }, - )?; - } - ResponsesTool::FileSearch(_) => { - record_name( - &mut names, - "file_search", - DeclaredNameOrigin::TopLevel { - description: "file search tool", - }, - )?; - } - ResponsesTool::CodeInterpreter(_) => { - record_name( - &mut names, - "code_interpreter", - DeclaredNameOrigin::TopLevel { - description: "code interpreter tool", - }, - )?; - } - ResponsesTool::Namespace(namespace) => { - for member in &namespace.tools { - let CodexNamespaceMember::Function(function) = member else { - continue; - }; - let name = model_visible_namespace_member_name(&namespace.name, function.name.as_str()); - record_name( - &mut names, - &name, - DeclaredNameOrigin::NamespaceMember { - namespace: &namespace.name, - member: function.name.as_str(), - }, - )?; - } - } - ResponsesTool::ToolSearch(_) | ResponsesTool::Mcp(_) | ResponsesTool::Unknown => {} - } - } - Ok(()) -} - -fn record_name<'a>( - names: &mut HashMap>, - name: &str, - origin: DeclaredNameOrigin<'a>, -) -> Result<(), ToolError> { - match names.entry(name.to_owned()) { - Entry::Vacant(entry) => { - entry.insert(origin); - Ok(()) - } - Entry::Occupied(existing) => { - let existing_description = existing.get().description(); - match origin { - DeclaredNameOrigin::NamespaceMember { namespace, member } => Err(ToolError::Config(format!( - "codex namespace member {namespace}.{member} at generated name {name}, which collides with a declared {existing_description}" - ))), - DeclaredNameOrigin::TopLevel { description } => Err(ToolError::Config(format!( - "{description} model-visible name '{name}' collides with declared {existing_description}" - ))), - } - } - } -} diff --git a/crates/agentic-server-core/src/tool/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index 38390af8..5b75d769 100644 --- a/crates/agentic-server-core/src/tool/normalize.rs +++ b/crates/agentic-server-core/src/tool/normalize.rs @@ -9,6 +9,7 @@ use super::function::FunctionHandler; use super::handler::{ToolError, ToolHandler, ToolOutput}; use super::mcp::McpHandler; use super::registry::ToolType; +use super::tool_search::ToolSearchHandler; use super::web_search::web_search_function_tool; impl ResponsesTool { @@ -28,25 +29,18 @@ impl ResponsesTool { "function tool config serialization failed".to_owned(), )), ), - Self::ToolSearch(param) => { - if param.description.trim().is_empty() { - return Err(ToolError::Config( - "tool_search description must not be empty or whitespace".to_owned(), - )); - } - if param.parameters.get("type").and_then(serde_json::Value::as_str) != Some("object") { - return Err(ToolError::Config( - "tool_search parameters must declare top-level JSON Schema type 'object'".to_owned(), - )); - } - Ok(()) - } Self::Mcp(param) => serialize_to_value_or_custom_default( param, "MCP tool config serialization failed", |param| McpHandler::spec_from_param(¶m).validate(¶m), Err(ToolError::Config("MCP tool config serialization failed".to_owned())), ), + Self::ToolSearch(param) => serialize_to_value_or_custom_default( + param, + "tool_search config serialization failed", + |param| ToolSearchHandler.validate(¶m), + Err(ToolError::Config("tool_search config serialization failed".to_owned())), + ), Self::WebSearch(_) | Self::FileSearch(_) | Self::CodeInterpreter(_) | Self::Unknown => Ok(()), Self::Namespace(param) => serialize_to_value_or_custom_default( param, @@ -90,6 +84,8 @@ impl ResponsesTool { /// /// - `Function` variants convert via [`From<&FunctionToolParam>`] for `FunctionTool`. /// Returns an empty list and logs at `debug` level if the name is empty. + /// - `ToolSearch` variants lower through [`ToolSearchHandler`] to the + /// synthetic client-executed function understood by vLLM. /// - `Mcp` variants convert gateway MCP built-ins to the function specs /// vLLM can call. /// - Unformatted `Custom` variants become function tools with one string @@ -111,10 +107,12 @@ impl ResponsesTool { |param| FunctionHandler.normalize(¶m).into_iter().take(1).collect(), vec![], ), - Self::ToolSearch(_) => { - tracing::debug!("tool_search declaration skipped until request-scoped state preparation"); - vec![] - } + Self::ToolSearch(param) => serialize_to_value_or_custom_default( + param, + "tool_search config serialization failed", + |param| ToolSearchHandler.normalize(¶m).into_iter().take(1).collect(), + vec![], + ), Self::Mcp(p) => serialize_to_value_or_custom_default( p, "MCP tool config serialization failed", diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index cd50d826..9067967b 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -7,12 +7,11 @@ use serde_json::Value; use super::codex::insert_namespace_entries; use super::custom::{CustomHandler, CustomToolMap, insert_custom_entry}; -use super::executors::{GatewayExecutors, McpServerToolsError}; +use super::executors::GatewayExecutors; use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; -use super::names::validate_model_visible_declared_names; -use super::search; +use super::tool_search::{TOOL_SEARCH_NAME, insert_tool_search_entry}; use super::web_search::insert_web_search_entry; use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; use crate::events::WireEvent; @@ -20,7 +19,7 @@ use crate::events::WireEvent; use crate::types::io::OutputItem; use crate::types::io::output::{FunctionToolCall, McpListTools}; use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool}; -use crate::utils::common::{serialize_to_value, serialize_to_value_or_custom_default}; +use crate::utils::common::serialize_to_value_or_custom_default; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -214,38 +213,17 @@ impl ToolRegistry { pub async fn build_with_handlers( tools: &mut [ResponsesTool], executors: &mut GatewayExecutors, - ) -> Result { - Self::build_with_mcp_config_failure_labels(tools, executors, None).await - } - - /// Build a request-scoped registry for active client tool search. - /// - /// Invalid MCP declarations remain request errors unless the search state - /// proves that the server definition was already loaded. A failure for a - /// loaded definition becomes the same sanitized `mcp_list_tools` failure - /// item used for connection and discovery errors, allowing inference to - /// continue without exposing private transport configuration. - pub(crate) async fn build_with_handlers_for_tool_search( - tools: &mut [ResponsesTool], - executors: &mut GatewayExecutors, - loaded_mcp_server_labels: &HashSet, - ) -> Result { - Self::build_with_mcp_config_failure_labels(tools, executors, Some(loaded_mcp_server_labels)).await - } - - async fn build_with_mcp_config_failure_labels( - tools: &mut [ResponsesTool], - executors: &mut GatewayExecutors, - recoverable_mcp_config_failures: Option<&HashSet>, ) -> Result { let mut entries = HashMap::with_capacity(tools.len()); let mut mcp_tool_map = McpToolMap::default(); let mut mcp_list_tools_items = Vec::new(); - // Validate declaration-derived names before MCP I/O, then key namespace - // members by the same flat name used in the private inference request. - // Discovered MCP names retain their post-list collision pass. - validate_model_visible_declared_names(tools)?; - let resolved_tools = CodexNamespaceHandler::resolve_namespace_members_after_validation(tools); + // Validate every declaration before MCP discovery performs external I/O. + tools.iter().try_for_each(ResponsesTool::validate)?; + + // Namespace members must be keyed by the same flat, model-visible name + // the model will call. Discovered MCP names retain their separate + // post-list collision pass. + let resolved_tools = CodexNamespaceHandler.resolve_namespace_members(tools)?; McpHandler::validate_server_labels(&resolved_tools)?; for (index, tool) in resolved_tools.iter().enumerate() { @@ -253,31 +231,20 @@ impl ToolRegistry { ResponsesTool::Function(p) => { insert_unique_tool_entries(&mut entries, |resolved| insert_function_entry(resolved, p))?; } - ResponsesTool::ToolSearch(_) => { - tracing::debug!("client-executed tool_search declaration is omitted from the dispatch registry"); + ResponsesTool::ToolSearch(param) => { + insert_unique_tool_entries(&mut entries, |resolved| { + insert_tool_search_entry(resolved, param); + })?; } ResponsesTool::Mcp(p) => { let tool_set = match executors.mcp_server_tools(p).await { Ok(tool_set) => tool_set, - Err(McpServerToolsError::RequestServerUrl(error)) - if recoverable_mcp_config_failures - .is_some_and(|labels| labels.contains(&p.server_label)) => - { + // Config errors mean the declaration is invalid; the client can fix it. + Err(error @ ToolError::Config(_)) => return Err(error), + Err(error) => { mcp_list_tools_items.push(McpHandler::failed_list_tools_item(&p.server_label, &error)); continue; } - Err(error) => { - let error = error.into_tool_error(); - match error { - // Config errors mean the declaration is invalid; the client can fix it. - error @ ToolError::Config(_) => return Err(error), - error => { - mcp_list_tools_items - .push(McpHandler::failed_list_tools_item(&p.server_label, &error)); - continue; - } - } - } }; let handlers = tool_set.discovered_handlers; mcp_list_tools_items.push(tool_set.list_tools_item); @@ -344,7 +311,9 @@ impl ToolRegistry { .map(|(name, entry)| (name.clone(), entry.tool_type)) .collect::>(); if self.tool_search_translation_enabled { - tool_types.insert("tool_search".to_owned(), ToolType::ToolSearch); + tool_types + .entry(TOOL_SEARCH_NAME.to_owned()) + .or_insert(ToolType::ToolSearch); } tool_types } @@ -373,10 +342,8 @@ impl ToolRegistry { &self.mcp_list_tools_items } - /// Reclassify only the synthetic function prepared by active request-scoped - /// tool-search state. An inactive ordinary function named `tool_search` - /// remains an ordinary client function. - pub(crate) fn classify_tool_search(&mut self, state: &ToolSearchState) -> Result<(), ToolError> { + /// Apply request-scoped tool-search translation and replay safeguards. + pub(crate) fn apply_tool_search_state(&mut self, state: &ToolSearchState) -> Result<(), ToolError> { self.tool_search_translation_enabled = state.is_active(); self.withheld_function_names.clone_from(state.withheld_function_names()); if !self.tool_search_translation_enabled { @@ -391,139 +358,27 @@ impl ToolRegistry { "a loaded tool collides with a withheld function name".to_owned(), )); } - if self - .mcp_tool_map - .resolves_call_before_load(state.mcp_load_positions(), state.unqualified_call_positions()) - { - return Err(ToolError::Config( - "request history calls an MCP function before its server definition is loaded".to_owned(), - )); - } - let Some(synthetic) = state.synthetic_function() else { + let Some(_) = state.synthetic_tool_search() else { return Ok(()); }; - let entry = self.entries.get_mut(&synthetic.name).ok_or_else(|| { - ToolError::Config("prepared tool-search function is missing from the private registry".to_owned()) + let entry = self.entries.get(TOOL_SEARCH_NAME).ok_or_else(|| { + ToolError::Config("prepared tool-search declaration is missing from the private registry".to_owned()) })?; - if entry.tool_type != ToolType::Function || entry.handler.is_some() { + if entry.tool_type != ToolType::ToolSearch || entry.tool_type.is_gateway_owned() || entry.handler.is_some() { return Err(ToolError::Config( - "prepared tool-search function has invalid private registry ownership".to_owned(), + "prepared tool-search declaration has invalid private registry ownership".to_owned(), )); } - entry.tool_type = ToolType::ToolSearch; Ok(()) } - #[must_use] - fn has_tool_search(&self) -> bool { - self.tool_search_translation_enabled - } - #[must_use] pub(crate) fn withheld_function_names(&self) -> &HashSet { &self.withheld_function_names } - /// Strictly convert a normalized blocking tool-search call to its public - /// representation before permissive response rehydration can discard or - /// default malformed fields. - pub(crate) fn normalize_blocking_response(&self, response: &mut Value) -> Result<(), ToolError> { - if !self.has_tool_search() { - return Ok(()); - } - let Some(items) = response.get_mut("output").and_then(Value::as_array_mut) else { - return Ok(()); - }; - let mut search_calls = 0_u8; - let mut public_ids = HashSet::with_capacity(items.len()); - let mut replacements = Vec::new(); - for (index, item) in items.iter().enumerate() { - let Some(object) = item.as_object() else { - continue; - }; - if object - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| self.withheld_function_names.contains(name)) - { - return Err(search::invalid_upstream_withheld_function_call()); - } - let reserved = object.get("name").and_then(Value::as_str) == Some("tool_search"); - let public_id = if reserved { - search_calls = search_calls.saturating_add(1); - if search_calls > 1 { - return Err(search::invalid_upstream_search_call()); - } - let replacement = normalize_raw_search_call(object)?; - let public_id = replacement["id"].as_str().unwrap_or_default().to_owned(); - replacements.push((index, replacement)); - public_id - } else { - object - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_default() - .to_owned() - }; - if !public_id.is_empty() && !public_ids.insert(public_id) { - return Err(search::invalid_upstream_search_call()); - } - } - for (index, replacement) in replacements { - items[index] = replacement; - } - Ok(()) - } - - /// Restore provider-normalized output types that are not already converted - /// by the strict raw blocking-response seam. - /// - /// # Errors - /// - /// Reserved for restoration failures reported by output adapters. - pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) -> Result<(), ToolError> { - if self.has_tool_search() { - let mut search_calls = 0_u8; - let mut public_ids = HashSet::with_capacity(output.len()); - let mut replacements = Vec::new(); - for (index, item) in output.iter().enumerate() { - if matches!(item, OutputItem::FunctionCall(call) if self.withheld_function_names.contains(&call.name)) { - return Err(search::invalid_upstream_withheld_function_call()); - } - let replacement = match item { - OutputItem::FunctionCall(call) if call.name == "tool_search" => { - search_calls = search_calls.saturating_add(1); - if search_calls > 1 - || call.status != crate::types::event::MessageStatus::Completed - || call.namespace.is_some() - { - return Err(search::invalid_upstream_search_call()); - } - Some(search::public_output_item(&call.id, &call.call_id, &call.arguments)?) - } - _ => None, - }; - let candidate = replacement.as_ref().unwrap_or(item); - let public_id = - serialize_to_value(candidate).map_err(|_| search::invalid_upstream_search_call())?["id"] - .as_str() - .filter(|id| !id.trim().is_empty()) - .unwrap_or_default() - .to_owned(); - if !public_id.is_empty() && !public_ids.insert(public_id) { - return Err(search::invalid_upstream_search_call()); - } - if let Some(replacement) = replacement { - replacements.push((index, replacement)); - } - } - for (index, replacement) in replacements { - output[index] = replacement; - } - } + pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) { CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref()); - Ok(()) } pub fn restore_stream_event_wire(&self, wire: &mut WireEvent) -> bool { @@ -579,11 +434,6 @@ impl ToolRegistry { } } -fn normalize_raw_search_call(object: &serde_json::Map) -> Result { - let public = search::public_output_item_from_raw(object)?; - serialize_to_value(&public).map_err(|_| search::invalid_upstream_search_call()) -} - #[cfg(test)] mod tests { use super::*; @@ -591,6 +441,7 @@ mod tests { use crate::tool::mcp::{McpDiscoveredHandler, McpHandler}; use crate::types::event::MessageStatus; use crate::types::tools::McpDiscoveredToolParam; + use crate::utils::common::serialize_to_value; fn declaration(server_label: &str) -> ResponsesTool { serde_json::from_value(serde_json::json!({ @@ -632,7 +483,7 @@ mod tests { } #[tokio::test] - async fn tool_search_declaration_has_no_registry_entry_or_handler() { + async fn tool_search_declaration_has_client_owned_registry_entry() { let mut tools: Vec = serde_json::from_value(serde_json::json!([{ "type": "tool_search", "execution": "client", @@ -644,25 +495,29 @@ mod tests { let registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors) .await - .expect("inert declaration does not require a handler"); + .expect("client-owned declaration does not require a handler"); - assert!(registry.lookup("tool_search").is_none()); - assert!(registry.entries.is_empty()); + let entry = registry.lookup("tool_search").expect("tool-search entry"); + assert_eq!(entry.tool_type, ToolType::ToolSearch); + assert_eq!(entry.config["description"], "Find a tool"); + assert!(entry.server_label.is_none()); + assert!(entry.handler.is_none()); + assert_eq!(registry.len(), 1); } #[tokio::test] - async fn synthetic_tool_search_is_client_owned_never_dispatched_and_normalizes_blocking_output() { - let (request, state) = prepared_search_state(); - let mut tools = private_tools(&state, &request); + async fn synthetic_tool_search_is_client_owned_never_dispatched_and_converts_to_typed_output() { + let (request, mut state) = prepared_search_state(); + let mut tools = private_tools(&mut state, &request); let mut executors = GatewayExecutors::default(); let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors) .await - .expect("synthetic function builds normally"); + .expect("typed tool-search declaration builds normally"); registry - .classify_tool_search(&state) - .expect("prepared synthetic entry is reclassified exactly once"); - let entry = registry.lookup("tool_search").expect("classified search entry"); + .apply_tool_search_state(&state) + .expect("prepared tool-search entry is already classified"); + let entry = registry.lookup("tool_search").expect("tool-search entry"); assert_eq!(entry.tool_type, ToolType::ToolSearch); assert!(!entry.tool_type.is_gateway_owned()); @@ -680,20 +535,9 @@ mod tests { "tool search has no gateway handler" ); - let mut response = serde_json::json!({"output": [{ - "type": "function_call", - "id": "fc_search_1", - "call_id": "call_search_1", - "name": "tool_search", - "namespace": null, - "arguments": "{\"query\":\"weather\"}", - "status": "completed" - }]}); - registry - .normalize_blocking_response(&mut response) - .expect("valid normalized search call converts once"); + let output = OutputItem::ToolSearchCall(crate::types::io::ToolSearchCall::try_from(&call).unwrap()); assert_eq!( - response["output"][0], + serialize_to_value(&output).unwrap(), serde_json::json!({ "type": "tool_search_call", "id": "tsc_search_1", @@ -706,125 +550,48 @@ mod tests { } #[tokio::test] - async fn raw_blocking_tool_search_normalization_is_strict_atomic_and_state_driven() { - let (request, state) = prepared_search_state(); - let mut tools = private_tools(&state, &request); - let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) - .await - .expect("registry"); - registry.classify_tool_search(&state).expect("classification"); - - let mut valid = serde_json::json!({"output": [{ - "type": "function_call", - "id": "fc_search", - "call_id": "call_search", - "name": "tool_search", - "namespace": null, - "arguments": "{\"query\":\"weather\"}", - "status": "completed" - }]}); - registry - .normalize_blocking_response(&mut valid) - .expect("valid raw call normalizes once"); - assert_eq!( - valid["output"][0], - serde_json::json!({ - "type": "tool_search_call", - "id": "tsc_search", - "call_id": "call_search", - "execution": "client", - "arguments": {"query": "weather"}, - "status": "completed" - }) - ); - - let invalid_items = [ - serde_json::json!({"type":"custom_tool_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}"}), - serde_json::json!({"type":"function_call","call_id":"call_search","name":"tool_search","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":" ","call_id":"call_search","name":"tool_search","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","name":"tool_search","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"","name":"tool_search","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":7,"name":"tool_search","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":null,"status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"[]","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"null","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}","status":"in_progress"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","arguments":"{}","status":7}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","namespace":"tools","arguments":"{}","status":"completed"}), - serde_json::json!({"type":"function_call","id":"fc_search","call_id":"call_search","name":"tool_search","namespace":7,"arguments":"{}","status":"completed"}), - ]; - for item in invalid_items { - let mut response = serde_json::json!({"output": [item]}); - assert!( - matches!( - registry.normalize_blocking_response(&mut response), - Err(ToolError::Execution(message)) if message == "upstream returned an invalid tool-search call" - ), - "malformed reserved raw call must fail atomically" - ); - } - - let raw_valid = serde_json::json!({ - "type":"function_call", "id":"fc_search", "call_id":"call_search", "name":"tool_search", - "namespace":null, "arguments":"{}", "status":"completed" - }); - let mut duplicate = serde_json::json!({"output": [raw_valid.clone(), raw_valid.clone()]}); - assert!(registry.normalize_blocking_response(&mut duplicate).is_err()); - let mut collision = serde_json::json!({"output": [ - raw_valid, - {"type":"message","id":"tsc_search","role":"assistant","content":[]} - ]}); - assert!(registry.normalize_blocking_response(&mut collision).is_err()); - - let mut ordinary_tools: Vec = serde_json::from_value(serde_json::json!([{ - "type": "function", "name": "tool_search", "parameters": {"type": "object"} - }])) - .expect("ordinary reserved-name function is valid while search is inactive"); - let ordinary = ToolRegistry::build_with_handlers(&mut ordinary_tools, &mut GatewayExecutors::default()) - .await - .expect("ordinary registry"); - let mut inactive_response = serde_json::json!({"output": [invalid_items_for_inactive()]}); - ordinary - .normalize_blocking_response(&mut inactive_response) - .expect("inactive ordinary function is never name-only validated as search"); - assert_eq!(inactive_response["output"][0]["type"], "function_call"); - } - - #[tokio::test] - async fn declaration_free_replay_enables_state_driven_raw_normalization() { - let (request, state) = replayed_search_state(); + async fn declaration_free_replay_enables_state_driven_classification() { + let (request, mut state) = replayed_search_state(); assert!(state.is_active()); - assert!(state.synthetic_function().is_none()); - let mut tools = private_tools(&state, &request); + assert!(state.synthetic_tool_search().is_none()); + let mut tools = private_tools(&mut state, &request); let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .expect("loaded function registry"); assert!(registry.lookup("tool_search").is_none()); registry - .classify_tool_search(&state) + .apply_tool_search_state(&state) .expect("enable replay translation"); - let mut valid = serde_json::json!({"output": [{ + let valid: FunctionToolCall = serde_json::from_value(serde_json::json!({ "type": "function_call", "id": "fc_search", "call_id": "call_search", "name": "tool_search", "namespace": null, "arguments": "{\"query\":\"news\"}", "status": "completed" - }]}); - registry - .normalize_blocking_response(&mut valid) - .expect("active replay translates reserved call"); - assert_eq!(valid["output"][0]["type"], "tool_search_call"); + })) + .unwrap(); + assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::ToolSearch)); + assert!(crate::types::io::ToolSearchCall::try_from(&valid).is_ok()); - let mut malformed = serde_json::json!({"output": [{ + let malformed: FunctionToolCall = serde_json::from_value(serde_json::json!({ "type": "function_call", "id": "fc_search", "call_id": "call_search", "name": "tool_search", "namespace": null, "arguments": "{}", "status": "in_progress" - }]}); - assert!(matches!( - registry.normalize_blocking_response(&mut malformed), - Err(ToolError::Execution(_)) - )); + })) + .unwrap(); + assert!(crate::types::io::ToolSearchCall::try_from(&malformed).is_err()); + } + + #[tokio::test] + async fn ordinary_function_named_tool_search_remains_a_function() { + let mut tools: Vec = serde_json::from_value(serde_json::json!([{ + "type": "function", + "name": "tool_search", + "parameters": {"type": "object"} + }])) + .unwrap(); + let registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) + .await + .unwrap(); + assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::Function)); } #[tokio::test] @@ -851,51 +618,34 @@ mod tests { "stream": false })) .expect("active namespace request"); - let state = ToolSearchState::build(&request).expect("prepared namespace state"); - let mut tools = private_tools(&state, &request); + let mut state = ToolSearchState::build(&request).expect("prepared namespace state"); + let mut tools = private_tools(&mut state, &request); let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .expect("private registry"); - registry.classify_tool_search(&state).expect("classification"); + registry.apply_tool_search_state(&state).expect("state application"); - let mut ordinary = serde_json::json!({"output": [{ - "type": "function_call", "id": "fc_ordinary", "call_id": "call_ordinary", - "name": "agentic_ns__weather__ordinary_prefix_like", "arguments": "{}", "status": "completed" - }]}); - registry - .normalize_blocking_response(&mut ordinary) - .expect("unrelated prefix-like function remains ordinary"); - assert_eq!(ordinary["output"][0]["type"], "function_call"); - - let mut withheld = serde_json::json!({"output": [{ - "type": "function_call", "id": "fc_withheld", "call_id": "call_withheld", - "name": "agentic_ns__weather__forecast", "arguments": "{}", "status": "completed" - }]}); - assert!(matches!( - registry.normalize_blocking_response(&mut withheld), - Err(ToolError::Execution(_)) - )); - } - - fn invalid_items_for_inactive() -> Value { - serde_json::json!({ - "type": "function_call", - "id": "fc_ordinary", - "call_id": "", - "name": "tool_search", - "arguments": "[]" - }) + assert!( + !registry + .withheld_function_names() + .contains("agentic_ns__weather__ordinary_prefix_like") + ); + assert!( + registry + .withheld_function_names() + .contains("agentic_ns__weather__forecast") + ); } fn private_tools( - state: &ToolSearchState, + state: &mut ToolSearchState, request: &crate::types::request_response::RequestPayload, ) -> Vec { + let mut request = request.clone(); state - .private_inference_request(request) - .expect("prepared state materializes a private inference request") - .tools - .expect("private tools") + .prepare_inference_request(&mut request) + .expect("prepared state materializes a private inference request"); + request.tools.expect("private tools") } fn prepared_search_state() -> (crate::types::request_response::RequestPayload, ToolSearchState) { @@ -941,46 +691,6 @@ mod tests { (request, state) } - #[test] - fn declaration_free_replay_keeps_stream_translation_active_without_a_dispatch_entry() { - let (_, state) = replayed_search_state(); - let mut registry = ToolRegistry::default(); - - registry.classify_tool_search(&state).expect("replay classification"); - - assert!(registry.lookup("tool_search").is_none()); - assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::ToolSearch)); - } - - #[test] - fn streaming_search_id_collision_is_atomic() { - let (_, state) = replayed_search_state(); - let mut registry = ToolRegistry::default(); - registry.classify_tool_search(&state).expect("replay classification"); - let mut output: Vec = serde_json::from_value(serde_json::json!([ - { - "type": "function_call", "id": "fc_same", "call_id": "call_search", - "name": "tool_search", "arguments": "{}", "status": "completed" - }, - { - "type": "tool_search_call", "id": "tsc_same", "call_id": "call_existing", - "execution": "client", "arguments": {}, "status": "completed" - } - ])) - .expect("output items"); - let before = serialize_to_value(&output).expect("before serializes"); - - let error = registry - .restore_final_payload_output(&mut output) - .expect_err("public ID collision must fail"); - - assert!(matches!( - error, - ToolError::Execution(message) if message == "upstream returned an invalid tool-search call" - )); - assert_eq!(serialize_to_value(&output).expect("after serializes"), before); - } - fn mixed_tool_declarations() -> Vec { serde_json::from_value(serde_json::json!([ { @@ -1015,9 +725,7 @@ mod tests { arguments: "{}".to_owned(), status: MessageStatus::Completed, })]; - registry - .restore_final_payload_output(&mut output) - .expect("ordinary namespace restoration succeeds"); + registry.restore_final_payload_output(&mut output); let OutputItem::FunctionCall(call) = &output[0] else { panic!("expected restored function call"); }; @@ -1172,158 +880,6 @@ mod tests { assert!(registry.is_empty()); } - fn invalid_private_mcp_declaration(server_label: &str) -> ResponsesTool { - serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": server_label, - "server_url": "http://url-user:url-password@127.0.0.1:1/mcp?token=query-secret", - "headers": {"X-Private-Token": "header-secret"}, - "authorization": "authorization-secret", - "require_approval": "never" - })) - .expect("invalid private MCP declaration remains a typed declaration") - } - - async fn assert_loaded_mcp_config_error( - tool: ResponsesTool, - mut executors: GatewayExecutors, - expected_message: &str, - ) { - let ResponsesTool::Mcp(param) = &tool else { - panic!("expected MCP declaration"); - }; - let loaded_mcp_server_labels = HashSet::from([param.server_label.clone()]); - let mut tools = vec![tool]; - - let error = - ToolRegistry::build_with_handlers_for_tool_search(&mut tools, &mut executors, &loaded_mcp_server_labels) - .await - .expect_err("loaded MCP policy error must remain a request error"); - - assert!( - matches!(error, ToolError::Config(ref message) if message.contains(expected_message)), - "unexpected loaded MCP configuration error: {error}" - ); - } - - #[tokio::test] - async fn ordinary_invalid_mcp_configuration_remains_a_config_error() { - let mut tools = vec![invalid_private_mcp_declaration("private_weather")]; - let mut executors = GatewayExecutors::default(); - - let error = ToolRegistry::build_with_handlers(&mut tools, &mut executors) - .await - .expect_err("ordinary invalid MCP configuration must fail the request"); - - assert!(matches!( - error, - ToolError::Config(message) - if message == "MCP server 'private_weather' has no valid request-declared configuration" - )); - } - - #[tokio::test] - async fn loaded_tool_search_mcp_config_failure_becomes_a_sanitized_list_tools_item() { - let mut tools = vec![invalid_private_mcp_declaration("private_weather")]; - let mut executors = GatewayExecutors::default(); - let loaded_mcp_server_labels = HashSet::from(["private_weather".to_owned()]); - - let registry = - ToolRegistry::build_with_handlers_for_tool_search(&mut tools, &mut executors, &loaded_mcp_server_labels) - .await - .expect("loaded MCP failure must retain public list-tools semantics"); - - let [list_tools] = registry.mcp_list_tools_items() else { - panic!("expected one MCP list-tools failure item"); - }; - assert_eq!(list_tools.server_label, "private_weather"); - assert!(list_tools.tools.is_empty()); - assert_eq!( - list_tools.error.as_deref(), - Some("MCP server 'private_weather' failed to connect or list tools") - ); - let public_item = serialize_to_value(list_tools) - .expect("public list-tools item serializes") - .to_string(); - for secret in [ - "url-user", - "url-password", - "query-secret", - "header-secret", - "authorization-secret", - ] { - assert!(!public_item.contains(secret), "public list-tools item leaked {secret}"); - } - assert!(registry.is_empty()); - } - - #[tokio::test] - async fn loaded_tool_search_mcp_policy_and_identity_errors_remain_strict() { - let connector = serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": "connector", - "connector_id": "connector_dropbox", - "require_approval": "never" - })) - .expect("connector declaration"); - assert_loaded_mcp_config_error(connector, GatewayExecutors::default(), "connector_id is not supported").await; - - let missing_approval = serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": "missing_approval", - "server_url": "http://127.0.0.1:1/mcp" - })) - .expect("missing approval declaration"); - assert_loaded_mcp_config_error( - missing_approval, - GatewayExecutors::default(), - "require_approval must be set to 'never'", - ) - .await; - - let unsupported_approval = serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": "unsupported_approval", - "server_url": "http://127.0.0.1:1/mcp", - "require_approval": "always" - })) - .expect("unsupported approval declaration"); - assert_loaded_mcp_config_error( - unsupported_approval, - GatewayExecutors::default(), - "approval gating is not yet supported", - ) - .await; - - let mut configured = GatewayExecutors::default(); - configured.insert(GatewayExecutorRegistration::Mcp { - server_label: "configured".to_owned(), - handlers: vec![discovered_handler("configured", "read", "mcp__configured__read")], - }); - let configured_override = serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": "configured", - "server_url": "http://127.0.0.1:1/mcp", - "require_approval": "never" - })) - .expect("configured override declaration"); - assert_loaded_mcp_config_error(configured_override, configured, "configured by the gateway").await; - - let mut filtered = GatewayExecutors::default(); - filtered.insert(GatewayExecutorRegistration::Mcp { - server_label: "filtered".to_owned(), - handlers: vec![discovered_handler("filtered", "delete", "mcp__filtered__delete")], - }); - let empty_allowed_tools = serde_json::from_value(serde_json::json!({ - "type": "mcp", - "server_label": "filtered", - "allowed_tools": ["read"], - "require_approval": "never" - })) - .expect("empty allowed tool declaration"); - assert_loaded_mcp_config_error(empty_allowed_tools, filtered, "empty final allowed tool set").await; - } - #[tokio::test] async fn duplicate_mcp_server_labels_are_rejected() { let mut tools = vec![declaration("counter"), declaration("counter")]; diff --git a/crates/agentic-server-core/src/tool/search.rs b/crates/agentic-server-core/src/tool/tool_search.rs similarity index 65% rename from crates/agentic-server-core/src/tool/search.rs rename to crates/agentic-server-core/src/tool/tool_search.rs index b356231e..21eb60bc 100644 --- a/crates/agentic-server-core/src/tool/search.rs +++ b/crates/agentic-server-core/src/tool/tool_search.rs @@ -2,24 +2,219 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use serde::Serialize; -use serde_json::Value; +use serde_json::{Map, Value}; use crate::types::event::MessageStatus; use crate::types::io::{ - FunctionTool, FunctionToolResultMessage, InputFunctionToolCall, InputItem, InputToolSearchCall, OutputItem, - ResponsesInput, ToolCallOutput, ToolChoice, ToolSearchCall, ToolSearchOutputMessage, + FunctionTool, FunctionToolResultMessage, InputFunctionToolCall, InputItem, InputToolSearchCall, ResponsesInput, + ToolCallOutput, ToolChoice, ToolSearchOutputMessage, }; -use crate::types::request_response::{RequestPayload, ToolSearchReadiness}; +use crate::types::request_response::RequestPayload; use crate::types::tools::{ - CodexNamespaceMember, CodexNamespaceToolParam, FunctionToolParam, NonEmptyToolName, ResponsesTool, - ToolSearchExecution, ToolSearchStatus, ToolSearchToolParam, + CodexNamespaceMember, CodexNamespaceToolParam, FunctionToolParam, ResponsesTool, ToolSearchStatus, + ToolSearchToolParam, }; -use crate::utils::common::{deserialize_from_str, serialize_to_string, serialize_to_value}; +use crate::utils::common::{ + deserialize_from_value, serialize_to_string, serialize_to_value, serialize_to_value_or_custom_default, +}; + +use super::CodexNamespaceHandler; +use super::handler::{ToolError, ToolHandler}; +use super::registry::{ToolEntry, ToolRegistry, ToolType}; + +pub(crate) const TOOL_SEARCH_NAME: &str = "tool_search"; +const DEFAULT_DESCRIPTION: &str = "Search the client tool catalog"; +const DEFAULT_QUERY_DESCRIPTION: &str = "A concise description of the needed capabilities."; + +/// Handler for client-executed `type: "tool_search"` declarations. +/// +/// The declaration remains a first-class tool-search type throughout request +/// preparation and registry construction. This handler performs the one +/// provider-specific lowering step to the ordinary function shape understood +/// by upstreams without native tool-search support. +#[derive(Debug)] +pub struct ToolSearchHandler; + +/// Request-scoped tool-search preparation owned by the tool layer. +/// +/// The executor carries this value for the lifetime of one turn, while the +/// underlying public/private projection state remains an implementation detail +/// of the tool-search behavior. +#[derive(Debug, Default)] +pub(crate) struct PreparedToolSearch { + state: Option, +} -use super::ToolError; -use super::names::validate_model_visible_declared_names; +impl PreparedToolSearch { + /// Build and consume the private inference projection for a fully + /// rehydrated request. + pub(crate) fn prepare( + request: &mut RequestPayload, + restored_loaded_tools: &[ResponsesTool], + restore_only_declared: bool, + ) -> Result { + let state = ToolSearchHandler::prepare_request(request, restored_loaded_tools, restore_only_declared)?; + Ok(Self { state }) + } -const SYNTHETIC_TOOL_NAME: &str = "tool_search"; + /// Apply the derived model-visible routing safeguards to a request registry. + pub(crate) fn apply_to_registry(&self, registry: &mut ToolRegistry) -> Result<(), ToolError> { + if let Some(state) = &self.state { + registry.apply_tool_search_state(state)?; + } + Ok(()) + } + + /// Return the public declarations for a response envelope when tool search + /// is active. `Some([])` intentionally differs from an inactive request. + #[must_use] + pub(crate) fn public_response_tools(&self) -> Option> { + let state = self.state.as_ref().filter(|state| state.is_active())?; + let mut tools = state.public_response_tools(); + for tool in &mut tools { + tool.sanitize_for_persistence(); + } + Some(tools) + } + + /// Move the public persistence projection out of active state. + pub(crate) fn take_public_metadata(&mut self) -> Option<(Option>, Vec)> { + self.state + .as_mut() + .filter(|state| state.is_active()) + .map(ToolSearchState::take_public_metadata) + } + + #[cfg(test)] + pub(crate) fn state(&self) -> Option<&ToolSearchState> { + self.state.as_ref() + } +} + +impl ToolSearchHandler { + /// Prepare the private inference view from fully rehydrated public state. + /// + /// # Errors + /// + /// Returns [`ToolError::Config`] when the public tool-search history or + /// effective tool selection is invalid. + pub(crate) fn prepare_request( + request: &mut RequestPayload, + restored_loaded_tools: &[ResponsesTool], + restore_only_declared: bool, + ) -> Result, ToolError> { + let mut state = + ToolSearchState::build_with_loaded_tools(request, restored_loaded_tools, restore_only_declared)?; + if !state.is_active() { + return Ok(None); + } + state.prepare_inference_request(request)?; + Ok(Some(state)) + } + + #[must_use] + pub(crate) fn normalized_param(param: &ToolSearchToolParam) -> ToolSearchToolParam { + let mut normalized = param.clone(); + normalized.description = Some( + param + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .unwrap_or(DEFAULT_DESCRIPTION) + .to_owned(), + ); + normalized.parameters = Some( + param + .parameters + .as_ref() + .filter(|parameters| parameters.get("type").and_then(Value::as_str) == Some("object")) + .cloned() + .unwrap_or_else(default_parameters), + ); + normalized + } + + #[must_use] + fn function_tool(param: &ToolSearchToolParam) -> FunctionTool { + let normalized = Self::normalized_param(param); + FunctionTool { + type_: "function".to_owned(), + name: TOOL_SEARCH_NAME.to_owned(), + description: normalized.description, + parameters: normalized.parameters.map(Value::Object), + strict: Some(true), + } + } +} + +impl ToolHandler for ToolSearchHandler { + fn tool_type(&self) -> ToolType { + ToolType::ToolSearch + } + + fn validate(&self, param: &Value) -> Result<(), ToolError> { + deserialize_from_value::(param.clone()) + .map(|_| ()) + .map_err(|error| ToolError::Config(format!("invalid tool_search declaration: {error}"))) + } + + fn normalize(&self, param: &Value) -> Vec { + match deserialize_from_value::(param.clone()) { + Ok(param) => vec![Self::function_tool(¶m)], + Err(error) => { + tracing::warn!(%error, "tool_search normalize called before validation"); + vec![] + } + } + } +} + +pub(crate) fn insert_tool_search_entry(entries: &mut HashMap, param: &ToolSearchToolParam) { + serialize_to_value_or_custom_default( + param, + "tool_search config serialization failed", + |config| { + if entries + .insert( + TOOL_SEARCH_NAME.to_owned(), + ToolEntry { + tool_type: ToolType::ToolSearch, + config, + server_label: None, + handler: None, + }, + ) + .is_some() + { + tracing::warn!( + name = TOOL_SEARCH_NAME, + "duplicate tool name — previous definition overwritten" + ); + } + }, + (), + ); +} + +fn default_parameters() -> Map { + let query = Map::from_iter([ + ("type".to_owned(), Value::String("string".to_owned())), + ( + "description".to_owned(), + Value::String(DEFAULT_QUERY_DESCRIPTION.to_owned()), + ), + ]); + let properties = Map::from_iter([("query".to_owned(), Value::Object(query))]); + Map::from_iter([ + ("type".to_owned(), Value::String("object".to_owned())), + ("properties".to_owned(), Value::Object(properties)), + ( + "required".to_owned(), + Value::Array(vec![Value::String("query".to_owned())]), + ), + ("additionalProperties".to_owned(), Value::Bool(false)), + ]) +} /// Stable public identity used to compare definitions accumulated from search outputs. /// @@ -29,13 +224,12 @@ const SYNTHETIC_TOOL_NAME: &str = "tool_search"; enum LoadedToolIdentity { Function(String), Namespace(String), - Mcp(String), } impl LoadedToolIdentity { fn name(&self) -> &str { match self { - Self::Function(name) | Self::Namespace(name) | Self::Mcp(name) => name, + Self::Function(name) | Self::Namespace(name) => name, } } @@ -43,7 +237,6 @@ impl LoadedToolIdentity { match self { Self::Function(_) => "function", Self::Namespace(_) => "namespace", - Self::Mcp(_) => "MCP server", } } } @@ -84,8 +277,6 @@ struct DefinitionAccumulator<'a> { definition_indexes: &'a mut HashMap, loaded_public_tools: &'a mut Vec, withheld_function_names: &'a mut HashSet, - mcp_load_positions: &'a mut HashMap, - trusted_restored_identities: &'a HashSet, prior_unknown_namespace_calls: HashMap>, unqualified_call_positions: HashMap, current_history_position: Option, @@ -97,7 +288,6 @@ struct DefinitionViews<'a> { definition_indexes: &'a mut HashMap, loaded_public_tools: &'a mut Vec, withheld_function_names: &'a mut HashSet, - mcp_load_positions: &'a mut HashMap, } #[derive(Serialize)] @@ -113,25 +303,18 @@ enum CatalogEntry { #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, - Mcp { - server_label: String, - #[serde(skip_serializing_if = "Option::is_none")] - server_description: Option, - }, } impl CatalogEntry { fn display_name(&self) -> &str { match self { Self::Function { name, .. } | Self::Namespace { name, .. } => name, - Self::Mcp { server_label, .. } => server_label, } } fn description(&self) -> Option<&str> { match self { Self::Function { description, .. } | Self::Namespace { description, .. } => description.as_deref(), - Self::Mcp { server_description, .. } => server_description.as_deref(), } } } @@ -139,17 +322,16 @@ impl CatalogEntry { /// Pure, request-scoped state derived from fully rehydrated public history. /// /// The state deliberately has no `Serialize` implementation and its `Debug` -/// output contains counts only. Canonical definitions can contain MCP -/// credentials and stay private to equality checks. +/// output contains counts only. pub struct ToolSearchState { activity: ToolSearchActivity, + has_completed_search: bool, public_effective_tools: Option>, private_upstream_tools: Option>, private_upstream_input: Option, loaded_public_tools: Vec, - synthetic_function: Option, + synthetic_tool_search: Option, withheld_function_names: HashSet, - mcp_load_positions: HashMap, unqualified_call_positions: HashMap, } @@ -159,6 +341,7 @@ impl fmt::Debug for ToolSearchState { .debug_struct("ToolSearchState") .field("activity", &self.activity) .field("active", &self.is_active()) + .field("has_completed_search", &self.has_completed_search) .field( "public_effective_tool_count", &self.public_effective_tools.as_ref().map_or(0, Vec::len), @@ -169,9 +352,8 @@ impl fmt::Debug for ToolSearchState { ) .field("loaded_public_tool_count", &self.loaded_public_tools.len()) .field("has_private_upstream_input", &self.private_upstream_input.is_some()) - .field("has_synthetic_function", &self.synthetic_function.is_some()) + .field("has_synthetic_tool_search", &self.synthetic_tool_search.is_some()) .field("withheld_function_count", &self.withheld_function_names.len()) - .field("mcp_load_count", &self.mcp_load_positions.len()) .field("unqualified_history_call_count", &self.unqualified_call_positions.len()) .finish() } @@ -181,13 +363,13 @@ impl Default for ToolSearchState { fn default() -> Self { Self { activity: ToolSearchActivity::Inactive, + has_completed_search: false, public_effective_tools: None, private_upstream_tools: None, private_upstream_input: None, loaded_public_tools: Vec::new(), - synthetic_function: None, + synthetic_tool_search: None, withheld_function_names: HashSet::new(), - mcp_load_positions: HashMap::new(), unqualified_call_positions: HashMap::new(), } } @@ -224,8 +406,7 @@ impl ToolSearchState { restore_only_declared: bool, ) -> Result { let active_input = request.input.model_input(); - let readiness = request.tool_search_readiness_for_input(active_input.as_ref())?; - if readiness == ToolSearchReadiness::Inactive { + if !validate_tool_search_request(request, active_input.as_ref())? { return Ok(Self::default()); } @@ -236,6 +417,10 @@ impl ToolSearchState { let has_search_history = input_items .iter() .any(|item| matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))); + let has_completed_search = !restored_loaded_tools.is_empty() + || input_items + .iter() + .any(|item| matches!(item, InputItem::ToolSearchOutput(_))); let declaration = request .tools .as_deref() @@ -258,11 +443,10 @@ impl ToolSearchState { index_initial_definitions(&public_tools, &mut definitions, &mut definition_indexes)?; let mut withheld_function_names = initial_withheld_function_names(&public_tools, &definitions, &definition_indexes)?; - let mut mcp_load_positions = HashMap::new(); let mut unqualified_call_positions = HashMap::new(); let mut loaded_public_tools = Vec::new(); - let trusted_restored_identities = restore_loaded_definitions( + restore_loaded_definitions( restored_loaded_tools, DefinitionViews { public_tools: &mut public_tools, @@ -270,7 +454,6 @@ impl ToolSearchState { definition_indexes: &mut definition_indexes, loaded_public_tools: &mut loaded_public_tools, withheld_function_names: &mut withheld_function_names, - mcp_load_positions: &mut mcp_load_positions, }, restore_only_declared, )?; @@ -282,36 +465,32 @@ impl ToolSearchState { definition_indexes: &mut definition_indexes, loaded_public_tools: &mut loaded_public_tools, withheld_function_names: &mut withheld_function_names, - mcp_load_positions: &mut mcp_load_positions, }, &mut unqualified_call_positions, - &trusted_restored_identities, )?; - validate_model_visible_declared_names(&public_tools)?; + CodexNamespaceHandler.validate_namespace_collisions(Some(&public_tools))?; let catalog = build_catalog(&public_tools, &definitions, &definition_indexes); - let synthetic_function = declaration - .map(|declaration| synthetic_function(declaration, &catalog)) - .transpose()?; + let synthetic_tool_search = declaration.map(|declaration| synthetic_tool_search(declaration, &catalog)); let private_tools = build_private_tools( &public_tools, &definitions, &definition_indexes, - synthetic_function.as_ref(), + synthetic_tool_search.as_ref(), ); let public_effective_tools = (tools_were_present || !public_tools.is_empty()).then_some(public_tools); let private_upstream_tools = (tools_were_present || !private_tools.is_empty()).then_some(private_tools); Ok(Self { activity: ToolSearchActivity::Active, + has_completed_search, public_effective_tools, private_upstream_tools, private_upstream_input: Some(private_upstream_input), loaded_public_tools, - synthetic_function, + synthetic_tool_search, withheld_function_names, - mcp_load_positions, unqualified_call_positions, }) } @@ -326,6 +505,19 @@ impl ToolSearchState { self.public_effective_tools.as_deref() } + /// Public declarations available for selection in response metadata. + /// Before search completes, the response echoes the declared catalog. Once + /// search resolves availability, it exposes only initially available and + /// loaded definitions while preserving their public namespace shape. + #[must_use] + pub(crate) fn public_response_tools(&self) -> Vec { + let public_tools = self.public_effective_tools.as_deref().unwrap_or_default(); + if !self.has_completed_search { + return public_tools.to_vec(); + } + available_public_tools(public_tools, &self.loaded_public_tools) + } + /// Public definitions resolved by completed search outputs, in first-load order. /// /// This remains separate from `public_effective_tools`: an initially @@ -335,10 +527,10 @@ impl ToolSearchState { &self.loaded_public_tools } - /// Private synthetic function declaration used by request-scoped registry and upstream normalization. + /// Private tool-search declaration used by request-scoped registry and upstream normalization. #[must_use] - pub const fn synthetic_function(&self) -> Option<&FunctionTool> { - self.synthetic_function.as_ref() + pub const fn synthetic_tool_search(&self) -> Option<&ToolSearchToolParam> { + self.synthetic_tool_search.as_ref() } #[must_use] @@ -346,32 +538,77 @@ impl ToolSearchState { &self.withheld_function_names } - #[must_use] - pub(crate) fn mcp_load_positions(&self) -> &HashMap { - &self.mcp_load_positions - } - - #[must_use] - pub(crate) fn unqualified_call_positions(&self) -> &HashMap { - &self.unqualified_call_positions - } - - /// Materialize the private inference request for function, namespace, and - /// MCP tools from views prepared in the single state-building pass. The - /// public request is borrowed and never mutated. + /// Replace the request's public tool-search views with the prepared private + /// input and tools used for inference. The retained state then contains + /// only public metadata needed after inference. /// /// # Errors /// /// Returns [`ToolError::Config`] when the effective tool choice conflicts /// with the prepared private tool set. - pub fn private_inference_request(&self, public: &RequestPayload) -> Result { - validate_effective_tool_choice(public.tool_choice.as_ref(), &self.withheld_function_names)?; - let mut private = public.clone(); - if let Some(input) = &self.private_upstream_input { - private.input.clone_from(input); + pub fn prepare_inference_request(&mut self, request: &mut RequestPayload) -> Result<(), ToolError> { + validate_effective_tool_choice(request.tool_choice.as_ref(), &self.withheld_function_names)?; + let input = self.private_upstream_input.take().ok_or_else(|| { + ToolError::Config("tool-search private inference input has already been consumed".to_owned()) + })?; + request.input = input; + request.tools = self.private_upstream_tools.take(); + Ok(()) + } + + pub(crate) fn take_public_metadata(&mut self) -> (Option>, Vec) { + ( + self.public_effective_tools.take(), + std::mem::take(&mut self.loaded_public_tools), + ) + } +} + +fn validate_tool_search_request(request: &RequestPayload, input: &ResponsesInput) -> Result { + if !request.contains_tool_search_state_for_input(input) { + return Ok(false); + } + + let tools = request.tools.as_deref().unwrap_or_default(); + if tools + .iter() + .filter(|tool| matches!(tool, ResponsesTool::ToolSearch(_))) + .count() + > 1 + { + return Err(ToolError::Config( + "tool search accepts at most one tool_search declaration".to_owned(), + )); + } + if request.parallel_tool_calls == Some(true) { + return Err(ToolError::Config( + "parallel_tool_calls must be false when tool search is active".to_owned(), + )); + } + + for tool in tools { + tool.validate()?; + if has_reserved_tool_search_name(tool) { + return Err(ToolError::Config( + "model-visible tool name 'tool_search' is reserved while tool search is active".to_owned(), + )); } - private.tools.clone_from(&self.private_upstream_tools); - Ok(private) + } + + Ok(true) +} + +fn has_reserved_tool_search_name(tool: &ResponsesTool) -> bool { + match tool { + ResponsesTool::Function(function) => function.name.as_str() == TOOL_SEARCH_NAME, + ResponsesTool::Custom(custom) => custom.name.as_str() == TOOL_SEARCH_NAME, + ResponsesTool::Namespace(namespace) => namespace.name == TOOL_SEARCH_NAME, + ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Unknown => false, } } @@ -404,79 +641,44 @@ fn restore_loaded_definitions( restored_loaded_tools: &[ResponsesTool], views: DefinitionViews<'_>, restore_only_declared: bool, -) -> Result, ToolError> { +) -> Result<(), ToolError> { let DefinitionViews { public_tools, definitions, definition_indexes, loaded_public_tools, withheld_function_names, - mcp_load_positions, } = views; - let no_trusted_restored_identities = HashSet::new(); - let mut trusted_restored_identities = HashSet::with_capacity(restored_loaded_tools.len()); let mut accumulator = DefinitionAccumulator { public_tools, definitions, definition_indexes, loaded_public_tools, withheld_function_names, - mcp_load_positions, - trusted_restored_identities: &no_trusted_restored_identities, prior_unknown_namespace_calls: HashMap::new(), unqualified_call_positions: HashMap::new(), current_history_position: None, }; for tool in restored_loaded_tools { - let Some(tool) = restored_definition_for_load( - tool, - accumulator.public_tools, - accumulator.definitions, - accumulator.definition_indexes, - restore_only_declared, - )? + let Some(tool) = restored_definition_for_load(tool, accumulator.definition_indexes, restore_only_declared)? else { continue; }; load_definition(&tool, &mut accumulator)?; - let identity = loaded_tool_identity(&tool)?.ok_or_else(|| { - ToolError::Config("stored tool-search availability contains an unsupported definition".to_owned()) - })?; - trusted_restored_identities.insert(identity); } - Ok(trusted_restored_identities) + Ok(()) } fn restored_definition_for_load( restored: &ResponsesTool, - public_tools: &[ResponsesTool], - definitions: &[DefinitionRecord], definition_indexes: &HashMap, restore_only_declared: bool, ) -> Result, ToolError> { let identity = loaded_tool_identity(restored)?.ok_or_else(|| { ToolError::Config("stored tool-search availability contains an unsupported definition".to_owned()) })?; - let Some(record) = definition_indexes - .get(identity.name()) - .and_then(|index| definitions.get(*index)) - else { - return Ok((!restore_only_declared).then(|| restored.clone())); - }; - if record.identity != identity { - return Ok(Some(restored.clone())); - } - let declared = &public_tools[record.public_index]; - if matches!((restored, declared), (ResponsesTool::Mcp(_), ResponsesTool::Mcp(_))) { - let mut sanitized = declared.clone(); - sanitized.sanitize_for_persistence(); - if canonical_definition(&sanitized)? != canonical_definition(restored)? { - return Err(ToolError::Config(format!( - "loaded definition for identity '{}' conflicts with its existing type, schema, description, or configuration", - identity.name() - ))); - } - return Ok(Some(declared.clone())); + if restore_only_declared && !definition_indexes.contains_key(identity.name()) { + return Ok(None); } Ok(Some(restored.clone())) } @@ -498,64 +700,12 @@ fn stable_hash(value: &str) -> u64 { }) } -pub(crate) fn public_output_item(item_id: &str, call_id: &str, arguments: &str) -> Result { - if item_id.trim().is_empty() || call_id.trim().is_empty() { - return Err(invalid_upstream_search_call()); - } - let arguments = deserialize_from_str::(arguments) - .ok() - .and_then(|value| value.as_object().cloned()) - .ok_or_else(invalid_upstream_search_call)?; - Ok(OutputItem::ToolSearchCall(ToolSearchCall { - id: public_item_id(item_id), - call_id: call_id.to_owned(), - execution: ToolSearchExecution::Client, - arguments, - status: ToolSearchStatus::Completed, - })) -} - -pub(crate) fn public_output_item_from_raw(object: &serde_json::Map) -> Result { - if object.get("type").and_then(Value::as_str) != Some("function_call") - || object.get("name").and_then(Value::as_str) != Some(SYNTHETIC_TOOL_NAME) - || object.get("status").and_then(Value::as_str) != Some("completed") - || object.get("namespace").is_some_and(|namespace| !namespace.is_null()) - { - return Err(invalid_upstream_search_call()); - } - let item_id = required_non_blank_string(object.get("id"))?; - let call_id = required_non_blank_string(object.get("call_id"))?; - let arguments = required_non_blank_string(object.get("arguments"))?; - public_output_item(item_id, call_id, arguments) -} - -pub(crate) fn public_added_item(item_id: &str, call_id: &str) -> Result { - if item_id.trim().is_empty() || call_id.trim().is_empty() { - return Err(invalid_upstream_search_call()); - } - Ok(serde_json::json!({ - "id": public_item_id(item_id), - "type": "tool_search_call", - "status": "in_progress", - "arguments": {}, - "call_id": call_id, - "execution": "client", - })) -} - -fn required_non_blank_string(value: Option<&Value>) -> Result<&str, ToolError> { - value - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(invalid_upstream_search_call) -} - pub(crate) fn invalid_upstream_search_call() -> ToolError { - ToolError::Execution("upstream returned an invalid tool-search call".to_owned()) + ToolError::InvalidUpstreamToolSearch } pub(crate) fn invalid_upstream_withheld_function_call() -> ToolError { - ToolError::Execution("upstream returned a call for a function that has not been loaded".to_owned()) + ToolError::UpstreamWithheldFunctionCall } fn index_initial_definitions( @@ -588,8 +738,9 @@ fn definition_record( ) -> Result { let namespace_members = match tool { ResponsesTool::Namespace(namespace) => Some(namespace_member_records(namespace, dynamically_loaded)?), - ResponsesTool::Function(_) | ResponsesTool::Mcp(_) => None, + ResponsesTool::Function(_) => None, ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) | ResponsesTool::WebSearch(_) | ResponsesTool::FileSearch(_) | ResponsesTool::CodeInterpreter(_) @@ -708,7 +859,6 @@ struct CanonicalToolSearchOutput<'a> { enum ModelVisibleLoadedTool<'a> { Function(ModelVisibleFunction<'a>), Namespace(ModelVisibleNamespace<'a>), - Mcp(ModelVisibleMcp<'a>), } #[derive(Serialize)] @@ -728,20 +878,10 @@ struct ModelVisibleNamespace<'a> { description: Option<&'a str>, } -#[derive(Serialize)] -struct ModelVisibleMcp<'a> { - #[serde(rename = "type")] - type_: &'static str, - server_label: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - server_description: Option<&'a str>, -} - fn prepare_history( input: &ResponsesInput, views: DefinitionViews<'_>, unqualified_call_positions: &mut HashMap, - trusted_restored_identities: &HashSet, ) -> Result { let ResponsesInput::Items(items) = input else { return Ok(input.clone()); @@ -756,7 +896,6 @@ fn prepare_history( definition_indexes, loaded_public_tools, withheld_function_names, - mcp_load_positions, } = views; let mut definition_accumulator = DefinitionAccumulator { public_tools, @@ -764,8 +903,6 @@ fn prepare_history( definition_indexes, loaded_public_tools, withheld_function_names, - mcp_load_positions, - trusted_restored_identities, prior_unknown_namespace_calls: HashMap::new(), unqualified_call_positions: std::mem::take(unqualified_call_positions), current_history_position: None, @@ -889,7 +1026,7 @@ fn prepare_search_call( Ok(InputItem::FunctionCall(InputFunctionToolCall { id: Some(call.id.clone()), call_id: call.call_id.clone(), - name: SYNTHETIC_TOOL_NAME.to_owned(), + name: TOOL_SEARCH_NAME.to_owned(), namespace: None, arguments: canonical_arguments, status: Some(MessageStatus::Completed), @@ -920,6 +1057,11 @@ fn prepare_search_output( "tool_search_output call_id does not match the preceding unresolved call".to_owned(), )); } + if output.status != ToolSearchStatus::Completed { + return Err(ToolError::Config( + "tool_search_output must be completed before it may load tool definitions".to_owned(), + )); + } for tool in &output.tools { load_definition(tool, definition_accumulator)?; } @@ -950,12 +1092,8 @@ fn model_visible_output_tools(tools: &[ResponsesTool]) -> Result Ok(ModelVisibleLoadedTool::Mcp(ModelVisibleMcp { - type_: "mcp", - server_label: &mcp.server_label, - server_description: mcp.server_description.as_deref(), - })), ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) | ResponsesTool::WebSearch(_) | ResponsesTool::FileSearch(_) | ResponsesTool::CodeInterpreter(_) @@ -974,16 +1112,6 @@ fn load_definition(tool: &ResponsesTool, definitions: &mut DefinitionAccumulator if let Some(index) = definitions.definition_indexes.get(identity.name()).copied() { let record = &mut definitions.definitions[index]; if record.identity != identity || record.canonical != canonical { - if record.identity == identity - && matches!(tool, ResponsesTool::Mcp(_)) - && definitions.trusted_restored_identities.contains(&identity) - { - let mut sanitized = definitions.public_tools[record.public_index].clone(); - sanitized.sanitize_for_persistence(); - if canonical_definition(&sanitized)? == canonical { - return Ok(()); - } - } return Err(ToolError::Config(format!( "loaded definition for identity '{}' conflicts with its existing type, schema, description, or configuration", identity.name() @@ -1008,19 +1136,6 @@ fn load_definition(tool: &ResponsesTool, definitions: &mut DefinitionAccumulator { return Err(withheld_function_history_call()); } - let deferred_mcp = matches!( - &definitions.public_tools[record.public_index], - ResponsesTool::Mcp(mcp) if mcp.defer_loading == Some(true) - ); - if let LoadedToolIdentity::Mcp(server_label) = &record.identity - && deferred_mcp - && let Some(position) = definitions.current_history_position - { - definitions - .mcp_load_positions - .entry(server_label.clone()) - .or_insert(position); - } record.loaded = true; definitions.withheld_function_names.remove(record.identity.name()); definitions @@ -1044,14 +1159,6 @@ fn load_definition(tool: &ResponsesTool, definitions: &mut DefinitionAccumulator &definitions.prior_unknown_namespace_calls, &definitions.unqualified_call_positions, )?, - ResponsesTool::Mcp(mcp) => { - if let Some(position) = definitions.current_history_position { - definitions - .mcp_load_positions - .entry(mcp.server_label.clone()) - .or_insert(position); - } - } _ => {} } let public_index = definitions.public_tools.len(); @@ -1170,8 +1277,8 @@ fn loaded_tool_identity(tool: &ResponsesTool) -> Result LoadedToolIdentity::Function(function.name.as_str().to_owned()), ResponsesTool::Namespace(namespace) => LoadedToolIdentity::Namespace(namespace.name.clone()), - ResponsesTool::Mcp(mcp) => LoadedToolIdentity::Mcp(mcp.server_label.clone()), ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) | ResponsesTool::WebSearch(_) | ResponsesTool::FileSearch(_) | ResponsesTool::CodeInterpreter(_) @@ -1187,7 +1294,7 @@ fn loaded_tool_identity(tool: &ResponsesTool) -> Result Some(CatalogEntry::Mcp { - server_label: mcp.server_label.clone(), - server_description: mcp.server_description.clone(), - }), ResponsesTool::Function(_) | ResponsesTool::Namespace(_) | ResponsesTool::Mcp(_) @@ -1263,9 +1366,9 @@ fn build_catalog( /// Catalog prose deliberately follows the provider-characterization shape: /// declaration text, then one ordered semicolon-delimited list of `name — /// description` entries. It never uses schemas or execution configuration. -fn synthetic_description(declaration: &ToolSearchToolParam, catalog: &[CatalogEntry]) -> String { +fn synthetic_description(description: &str, catalog: &[CatalogEntry]) -> String { if catalog.is_empty() { - return declaration.description.clone(); + return description.to_owned(); } let entries = catalog .iter() @@ -1287,38 +1390,32 @@ fn synthetic_description(declaration: &ToolSearchToolParam, catalog: &[CatalogEn let noun = if catalog.len() == 1 { "entry" } else { "entries" }; format!( "{}. Available catalog {noun}: {entries}.", - declaration.description.trim().trim_end_matches('.') + description.trim().trim_end_matches('.') ) } -fn synthetic_function(declaration: &ToolSearchToolParam, catalog: &[CatalogEntry]) -> Result { - let name = NonEmptyToolName::try_from(SYNTHETIC_TOOL_NAME) - .map_err(|_| ToolError::Config("reserved synthetic tool name is invalid".to_owned()))?; - let param = FunctionToolParam { - name, - description: Some(synthetic_description(declaration, catalog)), - parameters: Some(Value::Object(declaration.parameters.clone())), - strict: Some(true), - defer_loading: None, - extra: HashMap::new(), - }; - Ok(FunctionTool::from(¶m)) +fn synthetic_tool_search(declaration: &ToolSearchToolParam, catalog: &[CatalogEntry]) -> ToolSearchToolParam { + let mut normalized = ToolSearchHandler::normalized_param(declaration); + let description = normalized.description.as_deref().unwrap_or_default(); + normalized.description = Some(synthetic_description(description, catalog)); + normalized } fn build_private_tools( public_tools: &[ResponsesTool], definitions: &[DefinitionRecord], definition_indexes: &HashMap, - synthetic_function: Option<&FunctionTool>, + synthetic_tool_search: Option<&ToolSearchToolParam>, ) -> Vec { public_tools .iter() .filter_map(|tool| match tool { - ResponsesTool::ToolSearch(_) => synthetic_function.map(function_tool_as_response), - ResponsesTool::Function(_) | ResponsesTool::Namespace(_) | ResponsesTool::Mcp(_) => { + ResponsesTool::ToolSearch(_) => synthetic_tool_search.cloned().map(ResponsesTool::ToolSearch), + ResponsesTool::Function(_) | ResponsesTool::Namespace(_) => { private_definition(tool, definitions, definition_indexes) } - ResponsesTool::WebSearch(_) + ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) | ResponsesTool::FileSearch(_) | ResponsesTool::CodeInterpreter(_) | ResponsesTool::Custom(_) @@ -1327,6 +1424,78 @@ fn build_private_tools( .collect() } +fn available_public_tools(public_tools: &[ResponsesTool], loaded_tools: &[ResponsesTool]) -> Vec { + let mut loaded_functions = HashSet::new(); + let mut loaded_namespace_members = HashMap::<&str, HashSet<&str>>::new(); + for tool in loaded_tools { + match tool { + ResponsesTool::Function(function) => { + loaded_functions.insert(function.name.as_str()); + } + ResponsesTool::Namespace(namespace) => { + let members = loaded_namespace_members.entry(namespace.name.as_str()).or_default(); + members.extend(namespace.tools.iter().filter_map(|member| match member { + CodexNamespaceMember::Function(function) => Some(function.name.as_str()), + CodexNamespaceMember::Unknown => None, + })); + } + ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => {} + } + } + + public_tools + .iter() + .filter_map(|tool| match tool { + ResponsesTool::Function(function) => { + let loaded = loaded_functions.contains(function.name.as_str()); + if function.defer_loading == Some(true) && !loaded { + return None; + } + let mut available = function.clone(); + if loaded { + available.defer_loading = None; + } + Some(ResponsesTool::Function(available)) + } + ResponsesTool::Namespace(namespace) => { + let loaded_members = loaded_namespace_members.get(namespace.name.as_str()); + let mut available = namespace.clone(); + available.tools = available + .tools + .into_iter() + .filter_map(|member| match member { + CodexNamespaceMember::Function(mut function) => { + let loaded = loaded_members.is_some_and(|members| members.contains(function.name.as_str())); + if function.defer_loading == Some(true) && !loaded { + return None; + } + if loaded { + function.defer_loading = None; + } + Some(CodexNamespaceMember::Function(function)) + } + CodexNamespaceMember::Unknown => None, + }) + .collect(); + (!available.tools.is_empty()).then_some(ResponsesTool::Namespace(available)) + } + ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => Some(tool.clone()), + ResponsesTool::ToolSearch(_) => None, + }) + .collect() +} + fn private_definition( tool: &ResponsesTool, definitions: &[DefinitionRecord], @@ -1347,11 +1516,6 @@ fn private_definition( .as_ref(), ) .map(ResponsesTool::Namespace), - ResponsesTool::Mcp(mcp) if loaded || mcp.defer_loading != Some(true) => { - let mut mcp = mcp.clone(); - mcp.defer_loading = None; - Some(ResponsesTool::Mcp(mcp)) - } ResponsesTool::Function(_) | ResponsesTool::Mcp(_) | ResponsesTool::ToolSearch(_) @@ -1396,22 +1560,107 @@ fn namespace_has_withheld_member(member_records: Option<&NamespaceMemberRecords> member_records.is_some_and(|members| members.unloaded_count != 0) } -fn function_tool_as_response(function: &FunctionTool) -> ResponsesTool { - ResponsesTool::Function(FunctionToolParam { - name: NonEmptyToolName::try_from(function.name.as_str()) - .expect("the synthetic function uses a fixed non-empty name"), - description: function.description.clone(), - parameters: function.parameters.clone(), - strict: function.strict, - defer_loading: None, - extra: HashMap::new(), - }) -} - #[cfg(test)] mod tests { + use serde_json::json; + use super::*; - use crate::types::tools::McpDiscoveredToolParam; + + fn param(value: Value) -> ToolSearchToolParam { + let ResponsesTool::ToolSearch(param) = serde_json::from_value(value).expect("valid tool_search declaration") + else { + panic!("expected tool_search"); + }; + param + } + + #[test] + fn handler_validates_and_normalizes_exactly_one_function() { + let param = param(json!({ + "type": "tool_search", + "execution": "client", + "description": "Find matching tools", + "parameters": {"type": "object", "properties": {"term": {"type": "string"}}} + })); + let value = serde_json::to_value(¶m).unwrap(); + + ToolSearchHandler.validate(&value).unwrap(); + assert_eq!(ToolSearchHandler.tool_type(), ToolType::ToolSearch); + assert_eq!( + serde_json::to_value(ToolSearchHandler.normalize(&value)).unwrap(), + json!([{ + "type": "function", + "name": "tool_search", + "description": "Find matching tools", + "parameters": {"type": "object", "properties": {"term": {"type": "string"}}}, + "strict": true + }]) + ); + } + + #[test] + fn normalization_uses_safe_defaults() { + let param = param(json!({"type": "tool_search", "execution": "client", "description": " "})); + let value = serde_json::to_value(¶m).unwrap(); + assert_eq!( + serde_json::to_value(ToolSearchHandler.normalize(&value)).unwrap(), + json!([{ + "type": "function", + "name": "tool_search", + "description": "Search the client tool catalog", + "parameters": { + "type": "object", + "properties": {"query": { + "type": "string", + "description": "A concise description of the needed capabilities." + }}, + "required": ["query"], + "additionalProperties": false + }, + "strict": true + }]) + ); + } + + #[test] + fn prepared_response_tools_remove_request_scoped_mcp_secrets_and_discovery() { + let mut request: RequestPayload = serde_json::from_value(json!({ + "model": "test", + "input": "find weather", + "parallel_tool_calls": false, + "tools": [ + {"type": "tool_search", "execution": "client"}, + { + "type": "mcp", + "server_label": "weather", + "server_url": "https://mcp.example.test/mcp", + "headers": {"Authorization": "Bearer header-secret"}, + "authorization": "field-secret", + "_agentic_discovered_tools": [{ + "server_label": "weather", + "tool_name": "forecast", + "internal_name": "mcp__weather__forecast", + "tool": {"name": "forecast", "inputSchema": {"type": "object"}} + }] + } + ] + })) + .expect("request shape"); + + let prepared = PreparedToolSearch::prepare(&mut request, &[], false).expect("tool-search preparation"); + let serialized = serde_json::to_value(prepared.public_response_tools().expect("active public tools")) + .expect("public tools serialize"); + let serialized = serialized.to_string(); + + for secret in [ + "header-secret", + "field-secret", + "mcp__weather__forecast", + "_agentic_discovered_tools", + ] { + assert!(!serialized.contains(secret)); + } + } #[test] fn public_tool_search_item_ids_are_stable_and_domain_separated() { @@ -1424,8 +1673,75 @@ mod tests { } #[test] - fn internal_discovered_mcp_details_never_enter_model_visible_pair_projection() { - let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ + fn response_tools_after_search_keep_immediate_and_loaded_public_availability() { + let request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "store": false, + "tools": [ + { + "type": "tool_search", + "execution": "client", + "description": "Find tools", + "parameters": {"type": "object"} + }, + {"type": "function", "name": "always_ready"}, + {"type": "function", "name": "get_weather", "defer_loading": true}, + {"type": "function", "name": "not_loaded", "defer_loading": true}, + { + "type": "namespace", + "name": "travel", + "tools": [ + {"type": "function", "name": "always_ready_member"}, + {"type": "function", "name": "get_timezone", "defer_loading": true}, + {"type": "function", "name": "not_loaded_member", "defer_loading": true} + ] + } + ], + "input": [ + { + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather and timezone"} + }, + { + "type": "tool_search_output", + "call_id": "call_search_1", + "tools": [ + {"type": "function", "name": "get_weather", "defer_loading": true}, + { + "type": "namespace", + "name": "travel", + "tools": [{"type": "function", "name": "get_timezone", "defer_loading": true}] + } + ] + } + ] + })) + .expect("valid mixed-availability tool-search request"); + + let state = ToolSearchState::build(&request).expect("tool-search state"); + let tools = serialize_to_value(&state.public_response_tools()).expect("response tools serialize"); + assert_eq!( + tools, + serde_json::json!([ + {"type": "function", "name": "always_ready"}, + {"type": "function", "name": "get_weather"}, + { + "type": "namespace", + "name": "travel", + "tools": [ + {"type": "function", "name": "always_ready_member"}, + {"type": "function", "name": "get_timezone"} + ] + } + ]) + ); + } + + #[test] + fn tool_search_output_rejects_mcp_definitions() { + let request: RequestPayload = serde_json::from_value(serde_json::json!({ "model": "test", "store": false, "parallel_tool_calls": false, @@ -1448,51 +1764,18 @@ mod tests { "tools": [{ "type": "mcp", "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "defer_loading": true + "server_url": "https://mcp.example.test/mcp" }] } ] })) - .expect("valid tool-search request"); - let ResponsesInput::Items(items) = &mut request.input else { - panic!("test request uses item history") - }; - let InputItem::ToolSearchOutput(output) = &mut items[1] else { - panic!("test request contains a search output") - }; - let ResponsesTool::Mcp(mcp) = &mut output.tools[0] else { - panic!("test output returns MCP") - }; - mcp.discovered_tools.push(McpDiscoveredToolParam { - server_label: "weather".to_owned(), - tool_name: "discovered-tool-sentinel".to_owned(), - internal_name: "internal-name-sentinel".to_owned(), - tool: serde_json::from_value(serde_json::json!({ - "name": "discovered-tool-sentinel", - "description": "discovered-description-sentinel", - "inputSchema": { - "type": "object", - "properties": {"discovered-schema-sentinel": {"type": "string"}} - } - })) - .expect("valid discovered tool"), - }); + .expect("typed request"); - let state = ToolSearchState::build(&request).expect("internal execution state remains valid"); - let private = state - .private_inference_request(&request) - .expect("active state materializes a private inference request"); - let private_input = serialize_to_string(&private.input).expect("private input serializes"); - for forbidden in [ - "_agentic_discovered_tools", - "discovered-tool-sentinel", - "internal-name-sentinel", - "discovered-description-sentinel", - "discovered-schema-sentinel", - ] { - assert!(!private_input.contains(forbidden), "private input leaked {forbidden}"); - } + let error = ToolSearchState::build(&request).expect_err("MCP is not a client-loaded tool definition"); + + assert!(matches!( + error, + ToolError::Config(message) if message.contains("unsupported tool definition") + )); } } diff --git a/crates/agentic-server-core/src/types/io/input.rs b/crates/agentic-server-core/src/types/io/input.rs index 47f441d9..8da1afd6 100644 --- a/crates/agentic-server-core/src/types/io/input.rs +++ b/crates/agentic-server-core/src/types/io/input.rs @@ -193,15 +193,20 @@ pub struct InputToolSearchCall { pub status: ToolSearchStatus, } -impl From for InputToolSearchCall { - fn from(call: ToolSearchCall) -> Self { - Self { - id: call.id, - call_id: call.call_id, - execution: call.execution, - arguments: call.arguments, - status: call.status, +impl TryFrom<&ToolSearchCall> for InputToolSearchCall { + type Error = ToolSearchStatus; + + fn try_from(call: &ToolSearchCall) -> Result { + if call.status != ToolSearchStatus::Completed { + return Err(call.status); } + Ok(Self { + id: call.id.clone(), + call_id: call.call_id.clone(), + execution: call.execution, + arguments: call.arguments.clone(), + status: ToolSearchStatus::Completed, + }) } } @@ -484,6 +489,33 @@ mod tests { ); } + #[test] + fn tool_search_items_accept_documented_statuses() { + for status in ["in_progress", "completed", "incomplete"] { + let call: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search_1", + "arguments": {"query": "weather"}, + "status": status + })) + .expect("documented tool-search call status"); + let output: InputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_output", + "call_id": "call_search_1", + "status": status, + "tools": [] + })) + .expect("documented tool-search output status"); + + assert_eq!(serde_json::to_value(call).expect("call serializes")["status"], status); + assert_eq!( + serde_json::to_value(output).expect("output serializes")["status"], + status + ); + } + } + #[test] fn responses_input_detects_only_typed_tool_search_state() { let search: ResponsesInput = serde_json::from_value(serde_json::json!([{ @@ -537,12 +569,6 @@ mod tests { "arguments": "not an object", "status": "completed" }), - serde_json::json!({ - "type": "tool_search_output", - "call_id": "call_search_1", - "status": "in_progress", - "tools": [] - }), serde_json::json!({ "type": "tool_search_output", "call_id": "call_search_1" diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index 752ceb7f..81ac9e47 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -3,7 +3,7 @@ use serde_json::Value; use crate::events::EventPayload; use crate::executor::error::ExecutorError; -use crate::tool::ToolRegistry; +use crate::tool::{ToolError, ToolRegistry, tool_search}; use crate::types::event::MessageStatus; use crate::types::tools::{ToolSearchExecution, ToolSearchStatus}; use crate::utils::common::deserialize_from_value_opt; @@ -11,7 +11,7 @@ use crate::utils::uuid7_str; use super::input::{ CompactionItem, InputContent, InputFunctionToolCall, InputItem, InputMessage, InputMessageContent, - InputTextContent, deserialize_non_blank_string, + InputTextContent, InputToolSearchCall, deserialize_non_blank_string, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,7 +110,7 @@ pub struct FunctionToolCall { /// translation must populate both fields explicitly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolSearchCall { - #[serde(deserialize_with = "deserialize_tool_search_item_id")] + #[serde(deserialize_with = "deserialize_non_blank_string")] pub id: String, #[serde(deserialize_with = "deserialize_non_blank_string")] pub call_id: String, @@ -119,17 +119,76 @@ pub struct ToolSearchCall { pub status: ToolSearchStatus, } -fn deserialize_tool_search_item_id<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let id = deserialize_non_blank_string(deserializer)?; - if id.strip_prefix("tsc_").is_none_or(str::is_empty) { - return Err(serde::de::Error::custom( - "emitted tool_search_call id must use the 'tsc_' prefix with a non-empty suffix", - )); +impl TryFrom<&FunctionToolCall> for ToolSearchCall { + type Error = ToolError; + + fn try_from(call: &FunctionToolCall) -> Result { + let mut public = Self::started_from_function(call)?; + if call.status != MessageStatus::Completed { + return Err(tool_search::invalid_upstream_search_call()); + } + public.arguments = serde_json::from_str::(&call.arguments) + .ok() + .and_then(|value| value.as_object().cloned()) + .ok_or_else(tool_search::invalid_upstream_search_call)?; + public.status = ToolSearchStatus::Completed; + Ok(public) + } +} + +impl ToolSearchCall { + pub(crate) fn started_from_function(call: &FunctionToolCall) -> Result { + if call.id.trim().is_empty() + || call.call_id.trim().is_empty() + || call.name != "tool_search" + || call.namespace.is_some() + { + return Err(tool_search::invalid_upstream_search_call()); + } + Ok(Self { + id: tool_search::public_item_id(&call.id), + call_id: call.call_id.clone(), + execution: ToolSearchExecution::Client, + arguments: serde_json::Map::new(), + status: ToolSearchStatus::InProgress, + }) + } +} + +impl TryFrom<&EventPayload> for ToolSearchCall { + type Error = ToolError; + + fn try_from(payload: &EventPayload) -> Result { + let EventPayload::OutputItemAdded { + item_id, + call_id, + execution, + status, + arguments, + .. + } = payload + else { + return Err(tool_search::invalid_upstream_search_call()); + }; + let call_id = call_id + .as_deref() + .filter(|call_id| !call_id.trim().is_empty()) + .ok_or_else(tool_search::invalid_upstream_search_call)?; + if item_id.trim().is_empty() { + return Err(tool_search::invalid_upstream_search_call()); + } + let execution = execution.ok_or_else(tool_search::invalid_upstream_search_call)?; + if status.as_deref() != Some("in_progress") || arguments.as_ref().is_none_or(|value| !value.is_empty()) { + return Err(tool_search::invalid_upstream_search_call()); + } + Ok(Self { + id: item_id.clone(), + call_id: call_id.to_owned(), + execution, + arguments: serde_json::Map::new(), + status: ToolSearchStatus::InProgress, + }) } - Ok(id) } /// A freeform custom tool invocation. @@ -686,6 +745,17 @@ impl ApplyDone for FunctionToolCall { } } +impl ApplyDone for ToolSearchCall { + fn apply_done(&mut self, payload: &EventPayload, _buffer: &mut String) { + let EventPayload::OutputItemDone { item, .. } = payload else { + return; + }; + if let Some(call) = deserialize_from_value_opt::(item.clone()) { + *self = call; + } + } +} + impl ApplyDone for CustomToolCall { fn apply_done(&mut self, payload: &EventPayload, buffer: &mut String) { match payload { @@ -803,7 +873,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::ToolSearchCall(call) => Some(InputItem::ToolSearchCall(call.clone().into())), + Self::ToolSearchCall(call) => InputToolSearchCall::try_from(call).ok().map(InputItem::ToolSearchCall), Self::CustomToolCall(call) => Some(InputItem::FunctionCall(call.clone().into())), Self::Compaction(item) => Some(InputItem::Compaction(item.clone())), Self::WebSearchCall(_) | Self::McpCall(_) | Self::McpListTools(_) | Self::Unknown => None, @@ -820,7 +890,7 @@ mod tests { fn emitted_tool_search_call_is_explicit_and_requires_client_action() { let wire = serde_json::json!({ "type": "tool_search_call", - "id": "tsc_1", + "id": "provider_item_1", "call_id": "call_search_1", "execution": "client", "arguments": {"query": "weather"}, @@ -855,8 +925,6 @@ mod tests { for (field, value) in [ ("id", serde_json::json!(" ")), - ("id", serde_json::json!("fc_1")), - ("id", serde_json::json!("tsc_")), ("call_id", serde_json::json!(" ")), ("arguments", serde_json::json!("not an object")), ] { @@ -877,6 +945,59 @@ mod tests { } } + #[test] + fn unfinished_tool_search_call_statuses_deserialize_but_are_not_replayable() { + for status in ["in_progress", "incomplete"] { + let item: OutputItem = serde_json::from_value(serde_json::json!({ + "type": "tool_search_call", + "id": "provider_item_1", + "call_id": "call_search_1", + "execution": "client", + "arguments": {}, + "status": status + })) + .unwrap(); + + assert!(item.to_input_item().is_none()); + } + } + + #[test] + fn synthetic_function_call_conversion_is_validated() { + let valid = FunctionToolCall { + id: "fc_search".to_owned(), + call_id: "call_search".to_owned(), + name: "tool_search".to_owned(), + namespace: None, + arguments: r#"{"query":"weather"}"#.to_owned(), + status: MessageStatus::Completed, + }; + let public = ToolSearchCall::try_from(&valid).unwrap(); + assert_eq!(public.id, "tsc_search"); + assert_eq!(public.arguments["query"], "weather"); + + for invalid in [ + FunctionToolCall { + name: "ordinary".to_owned(), + ..valid.clone() + }, + FunctionToolCall { + namespace: Some("tools".to_owned()), + ..valid.clone() + }, + FunctionToolCall { + arguments: "[]".to_owned(), + ..valid.clone() + }, + FunctionToolCall { + status: MessageStatus::InProgress, + ..valid.clone() + }, + ] { + assert!(ToolSearchCall::try_from(&invalid).is_err()); + } + } + #[test] fn compaction_output_item_round_trips_with_type_tag() { let item: OutputItem = serde_json::from_value(serde_json::json!({ @@ -1051,6 +1172,9 @@ mod tests { name: None, namespace: None, call_id: None, + execution: None, + status: None, + arguments: None, }; let mut item = McpListTools::try_from(&added).unwrap(); assert_eq!(item.id, "mcpl_1"); diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index c3bae77a..7d664b04 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -42,15 +42,6 @@ fn default_true() -> bool { true } -/// Structural readiness result returned after validating public tool-search -/// declarations and replay-item wire shapes. The deterministic request-scoped -/// state builder consumes this result and owns ordered-history semantics. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ToolSearchReadiness { - Inactive, - StatePreparationRequired, -} - #[derive(Debug, Serialize)] pub struct UpstreamRequest<'a> { pub model: &'a str, @@ -119,109 +110,30 @@ impl RequestPayload { /// search declaration, or any declaration whose schema is deferred. #[must_use] pub fn contains_tool_search_state(&self) -> bool { - self.input.contains_tool_search_state() + self.contains_tool_search_state_for_input(&self.input) + } + + #[must_use] + pub(crate) fn contains_tool_search_state_for_input(&self, input: &ResponsesInput) -> bool { + input.contains_tool_search_state() || self .tools .as_deref() .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) } - /// Validate the retained public tool-search contract without lowering or - /// executing any declaration. - /// - /// # Errors - /// - /// Returns [`ToolError::Config`] for invalid cardinality, reserved-name or - /// serial-execution conflicts, malformed replay items, and unsupported - /// dynamically returned declarations. - pub(crate) fn tool_search_readiness(&self) -> Result { - self.tool_search_readiness_for_input(&self.input) - } - - pub(crate) fn tool_search_readiness_for_input( - &self, - input: &ResponsesInput, - ) -> Result { - let tools = self.tools.as_deref().unwrap_or_default(); - let declaration_count = tools - .iter() - .filter(|tool| matches!(tool, ResponsesTool::ToolSearch(_))) - .count(); - let input_items = match input { - ResponsesInput::Text(_) => &[][..], - ResponsesInput::Items(items) => items.as_slice(), - }; - let active = input.contains_tool_search_state() || tools.iter().any(tool_activates_tool_search); - if !active { - return Ok(ToolSearchReadiness::Inactive); - } - - if declaration_count > 1 { - return Err(ToolError::Config( - "tool search accepts at most one tool_search declaration".to_owned(), - )); - } - if self.parallel_tool_calls == Some(true) { - return Err(ToolError::Config( - "parallel_tool_calls must be false when tool search is active".to_owned(), - )); - } - - for tool in tools { - tool.validate()?; - if let ResponsesTool::Namespace(namespace) = tool { - validate_tool_search_namespace(namespace)?; - } - if has_reserved_tool_search_name(tool) { - return Err(ToolError::Config( - "model-visible tool name 'tool_search' is reserved while tool search is active".to_owned(), - )); - } - } - - for item in input_items { - match item { - InputItem::ToolSearchCall(call) => { - if call.id.trim().is_empty() { - return Err(ToolError::Config("tool_search_call id must not be blank".to_owned())); - } - if call.call_id.trim().is_empty() { - return Err(ToolError::Config( - "tool_search_call call_id must not be blank".to_owned(), - )); - } - } - InputItem::ToolSearchOutput(output) => { - if output.call_id.trim().is_empty() { - return Err(ToolError::Config( - "tool_search_output call_id must not be blank".to_owned(), - )); - } - for tool in &output.tools { - validate_loaded_tool(tool)?; - } - } - InputItem::Message(_) - | InputItem::FunctionCall(_) - | InputItem::FunctionCallOutput(_) - | InputItem::CustomToolCall(_) - | InputItem::CustomToolCallOutput(_) - | InputItem::Reasoning(_) - | InputItem::Compaction(_) - | InputItem::CompactionTrigger - | InputItem::Unknown => {} - } - } - - Ok(ToolSearchReadiness::StatePreparationRequired) - } - - pub(crate) fn ensure_tool_search_ready(&self) -> Result<(), ToolError> { - match self.tool_search_readiness()? { - ToolSearchReadiness::Inactive => Ok(()), - ToolSearchReadiness::StatePreparationRequired => Err(ToolError::Config( + fn ensure_tool_search_ready(&self) -> Result<(), ToolError> { + if self.input.contains_tool_search_state() + || self + .tools + .as_deref() + .is_some_and(|tools| tools.iter().any(tool_has_deferred_definition)) + { + Err(ToolError::Config( "tool_search requests require prepared request-scoped state before upstream conversion".to_owned(), - )), + )) + } else { + Ok(()) } } @@ -287,76 +199,24 @@ impl RequestPayload { } } -fn has_reserved_tool_search_name(tool: &ResponsesTool) -> bool { - match tool { - ResponsesTool::Function(function) => function.name.as_str() == "tool_search", - ResponsesTool::Custom(custom) => custom.name.as_str() == "tool_search", - ResponsesTool::Namespace(namespace) => namespace.name == "tool_search", - ResponsesTool::ToolSearch(_) - | ResponsesTool::Mcp(_) - | ResponsesTool::WebSearch(_) - | ResponsesTool::FileSearch(_) - | ResponsesTool::CodeInterpreter(_) - | ResponsesTool::Unknown => false, - } +fn tool_activates_tool_search(tool: &ResponsesTool) -> bool { + matches!(tool, ResponsesTool::ToolSearch(_)) || tool_has_deferred_definition(tool) } -fn tool_activates_tool_search(tool: &ResponsesTool) -> bool { +fn tool_has_deferred_definition(tool: &ResponsesTool) -> bool { match tool { - ResponsesTool::ToolSearch(_) => true, ResponsesTool::Function(function) => function.defer_loading == Some(true), - ResponsesTool::Mcp(mcp) => mcp.defer_loading == Some(true), ResponsesTool::Namespace(namespace) => namespace.tools.iter().any( |member| matches!(member, CodexNamespaceMember::Function(function) if function.defer_loading == Some(true)), ), - ResponsesTool::WebSearch(_) - | ResponsesTool::FileSearch(_) - | ResponsesTool::CodeInterpreter(_) - | ResponsesTool::Custom(_) - | ResponsesTool::Unknown => false, - } -} - -fn validate_loaded_tool(tool: &ResponsesTool) -> Result<(), ToolError> { - match tool { - ResponsesTool::Function(_) | ResponsesTool::Mcp(_) => {} - ResponsesTool::Namespace(namespace) => validate_tool_search_namespace(namespace)?, ResponsesTool::ToolSearch(_) - | ResponsesTool::Custom(_) + | ResponsesTool::Mcp(_) | ResponsesTool::WebSearch(_) | ResponsesTool::FileSearch(_) | ResponsesTool::CodeInterpreter(_) - | ResponsesTool::Unknown => { - return Err(ToolError::Config( - "tool_search_output contains an unsupported tool type".to_owned(), - )); - } - } - tool.validate()?; - if has_reserved_tool_search_name(tool) { - return Err(ToolError::Config( - "loaded model-visible tool name 'tool_search' is reserved".to_owned(), - )); - } - Ok(()) -} - -fn validate_tool_search_namespace(namespace: &crate::types::tools::CodexNamespaceToolParam) -> Result<(), ToolError> { - if namespace.tools.is_empty() { - return Err(ToolError::Config( - "tool-search namespaces must contain at least one function member".to_owned(), - )); - } - if namespace - .tools - .iter() - .any(|member| !matches!(member, CodexNamespaceMember::Function(_))) - { - return Err(ToolError::Config( - "tool-search namespaces may contain only function members".to_owned(), - )); + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => false, } - Ok(()) } /// Server-side context management configuration for a Responses request. @@ -413,6 +273,10 @@ pub struct ResponsePayload { pub previous_response_id: Option, pub conversation_id: Option, pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, } impl ResponsePayload { @@ -526,153 +390,6 @@ mod tests { serde_json::from_value(request).expect("request fixture should deserialize") } - #[test] - fn tool_search_contract_rejects_request_wide_violations() { - let cases = [ - ( - "multiple declarations", - tool_search_request( - vec![tool_search_declaration(), tool_search_declaration()], - serde_json::json!("hi"), - None, - ), - ), - ( - "parallel calling", - tool_search_request(vec![tool_search_declaration()], serde_json::json!("hi"), Some(true)), - ), - ( - "reserved function name", - tool_search_request( - vec![ - tool_search_declaration(), - serde_json::json!({"type": "function", "name": "tool_search"}), - ], - serde_json::json!("hi"), - None, - ), - ), - ( - "reserved custom name", - tool_search_request( - vec![ - tool_search_declaration(), - serde_json::json!({"type": "custom", "name": "tool_search"}), - ], - serde_json::json!("hi"), - None, - ), - ), - ( - "unsupported returned tool", - tool_search_request( - vec![tool_search_declaration()], - serde_json::json!([ - { - "type": "tool_search_call", - "id": "tsc_1", - "call_id": "call_search_1", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": [{"type": "custom", "name": "raw"}] - } - ]), - None, - ), - ), - ]; - - for (case, request) in cases { - assert!( - request.to_upstream_request(false).is_err(), - "tool-search request should reject {case}" - ); - } - } - - fn request_with_returned_tools(returned_tools: Vec) -> RequestPayload { - let returned_tools = Value::Array(returned_tools); - tool_search_request( - vec![tool_search_declaration()], - serde_json::json!([ - { - "type": "tool_search_call", - "id": "tsc_1", - "call_id": "call_search_1", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": returned_tools - } - ]), - Some(false), - ) - } - - #[test] - fn tool_search_contract_accepts_supported_returned_tool_kinds() { - for returned_tools in [ - vec![], - vec![serde_json::json!({ - "type": "function", - "name": "get_weather", - "parameters": {"type": "object"} - })], - vec![serde_json::json!({ - "type": "namespace", - "name": "weather", - "tools": [{ - "type": "function", - "name": "forecast", - "parameters": {"type": "object"} - }] - })], - vec![serde_json::json!({ - "type": "mcp", - "server_label": "weather", - "server_url": "https://mcp.example.test" - })], - ] { - assert_eq!( - request_with_returned_tools(returned_tools) - .tool_search_readiness() - .expect("supported returned tools pass contract validation"), - ToolSearchReadiness::StatePreparationRequired - ); - } - } - - #[test] - fn tool_search_contract_accepts_declaration_free_manual_public_replay() { - assert_eq!( - tool_search_request( - vec![], - serde_json::json!([ - { - "type": "tool_search_call", - "id": "tsc_1", - "call_id": "call_search_1", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": [] - } - ]), - Some(false), - ) - .tool_search_readiness() - .expect("manual public replay is valid without redeclaring tool_search"), - ToolSearchReadiness::StatePreparationRequired - ); - } - #[test] fn deferred_declarations_require_tool_search_state_preparation() { for tool in [ @@ -681,12 +398,6 @@ mod tests { "name": "deferred_function", "defer_loading": true }), - serde_json::json!({ - "type": "mcp", - "server_label": "deferred_server", - "server_url": "https://mcp.example.test/mcp", - "defer_loading": true - }), serde_json::json!({ "type": "namespace", "name": "deferred_namespace", @@ -699,43 +410,10 @@ mod tests { ] { let request = tool_search_request(vec![tool], serde_json::json!("hi"), Some(false)); assert!(request.contains_tool_search_state()); - assert_eq!( - request - .tool_search_readiness() - .expect("deferred declaration is a valid tool-search trigger"), - ToolSearchReadiness::StatePreparationRequired - ); assert!(request.to_upstream_request(false).is_err()); } } - #[test] - fn tool_search_contract_rejects_every_unsupported_returned_tool_kind() { - for returned_tool in [ - serde_json::json!({"type": "web_search_preview"}), - serde_json::json!({"type": "file_search", "vector_store_ids": []}), - serde_json::json!({"type": "code_interpreter"}), - serde_json::json!({"type": "custom", "name": "raw"}), - tool_search_declaration(), - serde_json::json!({"type": "future_tool", "opaque": true}), - serde_json::json!({ - "type": "namespace", - "name": "mixed", - "tools": [ - {"type": "function", "name": "valid"}, - {"type": "future_member", "opaque": true} - ] - }), - ] { - assert!( - request_with_returned_tools(vec![returned_tool]) - .tool_search_readiness() - .is_err(), - "unsupported returned tool kind must fail validation" - ); - } - } - #[test] fn to_upstream_request_rejects_unprepared_tool_search_state() { let request = tool_search_request( @@ -1204,6 +882,8 @@ mod tests { previous_response_id: None, conversation_id: None, instructions: None, + tools: None, + tool_choice: None, }; for (status, expected_type) in [ @@ -1237,6 +917,8 @@ mod tests { previous_response_id: None, conversation_id: None, instructions: None, + tools: None, + tool_choice: None, }; let chunk = payload.as_created_response_chunk(); diff --git a/crates/agentic-server-core/src/types/tools/params.rs b/crates/agentic-server-core/src/types/tools/params.rs index 7b2f7a3c..59c75edd 100644 --- a/crates/agentic-server-core/src/types/tools/params.rs +++ b/crates/agentic-server-core/src/types/tools/params.rs @@ -151,25 +151,27 @@ pub struct CustomToolParam { pub enum ToolSearchExecution { #[default] Client, + // TODO: Support `Server` execution type for gateway built-in tool } -/// Terminal tool-search calls and outputs are accepted only after completion. -/// Streaming `in_progress` state belongs to event lifecycle payloads rather -/// than this persisted public-item status. +/// Lifecycle status of a public tool-search call or output item. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolSearchStatus { + InProgress, #[default] Completed, + Incomplete, } /// Parameters for a client-executed tool-search declaration. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ToolSearchToolParam { pub execution: ToolSearchExecution, - pub description: String, - pub parameters: serde_json::Map, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, } /// Parameters for a gateway MCP built-in tool declaration. @@ -177,8 +179,6 @@ pub struct ToolSearchToolParam { pub struct McpToolParam { pub server_label: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub server_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub connector_id: Option, @@ -190,8 +190,6 @@ pub struct McpToolParam { pub allowed_tools: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub require_approval: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub defer_loading: Option, /// Request-scoped `tools/list` results used by MCP normalization. This /// field is populated internally and ignored on the public request wire. #[serde( @@ -388,9 +386,7 @@ mod tests { let mut tool = serde_json::from_value::(serde_json::json!({ "type": "mcp", "server_label": "repo", - "server_description": "Repository tools", - "server_url": "https://mcp.example.test/mcp?continuation_token=query-secret", - "defer_loading": true, + "server_url": "https://mcp.example.test/mcp", "headers": { "Authorization": "Bearer header-secret", "X-Request-ID": "request-1" @@ -422,12 +418,7 @@ mod tests { assert!(persisted.get("authorization").is_none()); assert!(persisted.get("_agentic_discovered_tools").is_none()); assert_eq!(persisted["server_label"], "repo"); - assert_eq!(persisted["server_description"], "Repository tools"); - assert_eq!( - persisted["server_url"], "https://mcp.example.test/mcp?continuation_token=query-secret", - "persistence preserves the complete endpoint for continuation; model-visible catalogs and public failures must redact it" - ); - assert_eq!(persisted["defer_loading"], true); + assert_eq!(persisted["server_url"], "https://mcp.example.test/mcp"); assert_eq!(persisted["allowed_tools"], serde_json::json!(["read_file"])); assert_eq!(persisted["require_approval"], "never"); } @@ -453,13 +444,37 @@ mod tests { !tool.is_gateway_owned(), "client-executed tool search must bypass gateway dispatch" ); - assert!( - tool.to_function_tools().is_empty(), - "client-executed tool search must bypass generic upstream normalization" + assert_eq!( + serde_json::to_value(tool.to_function_tools()).unwrap(), + serde_json::json!([{ + "type": "function", + "name": "tool_search", + "description": "Find a tool for the requested task", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + }, + "strict": true + }]), + "the upstream-normalization boundary lowers tool search exactly once" ); assert_eq!(serde_json::to_value(tool).expect("tool serializes"), declaration); } + #[test] + fn responses_tool_search_declaration_omits_optional_fields() { + let declaration = serde_json::json!({ + "type": "tool_search", + "execution": "client" + }); + + let tool: ResponsesTool = serde_json::from_value(declaration.clone()).expect("valid minimal declaration"); + + tool.validate().expect("omitted optional fields are valid"); + assert_eq!(serde_json::to_value(tool).expect("tool serializes"), declaration); + } + #[test] fn responses_tool_search_declaration_rejects_invalid_wire_shapes() { for declaration in [ @@ -474,11 +489,6 @@ mod tests { "description": "Hosted execution is excluded", "parameters": {"type": "object"} }), - serde_json::json!({ - "type": "tool_search", - "execution": "client", - "description": "Missing parameters" - }), serde_json::json!({ "type": "tool_search", "execution": "client", @@ -494,7 +504,7 @@ mod tests { } #[test] - fn responses_tool_search_declaration_rejects_bad_description_or_schema() { + fn responses_tool_search_declaration_accepts_model_facing_values_for_private_normalization() { for (description, parameters) in [ (" ", serde_json::json!({"type": "object"})), ("Find a tool", serde_json::json!({})), @@ -508,10 +518,8 @@ mod tests { })) .expect("structurally valid declaration"); - assert!( - tool.validate().is_err(), - "invalid declaration semantics must fail validation" - ); + tool.validate() + .expect("typed public values are normalized only when building the private synthetic function"); } } diff --git a/crates/agentic-server-core/tests/cassettes/README.md b/crates/agentic-server-core/tests/cassettes/README.md index 02b9427f..321ce3fc 100644 --- a/crates/agentic-server-core/tests/cassettes/README.md +++ b/crates/agentic-server-core/tests/cassettes/README.md @@ -81,6 +81,8 @@ user blocks emitted by Claude Code 2.1.218. --openai URL OpenAI upstream (default https://api.openai.com) --tools FILE JSON file containing a tools array (responses mode only) --tool-choice VALUE "auto", "none", "required", or JSON e.g. '{"type":"function","name":"foo"}' +--tool-choice-sequence FILE + JSON array with one tool_choice value per linear Responses turn --tool-outputs FILE JSON object mapping called tool names to output strings --tool-search-output-tools FILE JSON array returned for a client tool-search call @@ -202,7 +204,7 @@ turns: | `record_custom_tool_cassettes.sh` | Matching two-turn custom-tool flows (streaming + non-streaming) | gateway and OpenAI reference | | `record_mcp_cassettes.sh` | Native MCP counter tool discovery and calls (streaming + non-streaming) | gateway and OpenAI reference | | `record_web_search_cassettes.sh` | Matching web-search calls (streaming + non-streaming) | gateway and OpenAI reference | -| `record_tool_search_cassettes.sh` | Three-turn client tool-search characterization; gateway blocking, HTTP/SSE, and WebSocket acceptance | OpenAI reference, direct vLLM, and gateway | +| `record_tool_search_cassettes.sh` | Four-turn mixed function/namespace client tool-search characterization; gateway blocking, HTTP/SSE, and WebSocket acceptance | OpenAI reference, direct vLLM, and gateway | ### Text-only (OpenAI) @@ -229,15 +231,30 @@ VLLM_URL=http://0.0.0.0:5050 MODEL=Qwen/Qwen3-30B-A3B-FP8 bash tests/cassettes/r ### Client tool search (OpenAI reference, direct vLLM, and gateway) -The recorder captures three turns: search call, linked search output and loaded function call, then linked function -output and final message. OpenAI and gateway use public `tool_search_call`/`tool_search_output`; direct vLLM uses a -private synthetic `tool_search` function. Direct vLLM and gateway blocking use `store: false` full-item replay; -gateway SSE/WebSocket profiles use stored continuation. The private projection is not the gateway-to-vLLM envelope. +The recorder captures four turns: a search call, a linked search output followed by one loaded ordinary function +call, its linked function call output followed by one loaded namespace-member call, then that call's linked output and +the final message. The initial catalog contains several deferred ordinary functions and a namespace with several +deferred members; the search output loads exactly one ordinary function and exactly one member of that namespace. +OpenAI and gateway use public `tool_search_call`/`tool_search_output` and public `{ namespace, name }` calls; direct +vLLM uses a private synthetic `tool_search` function and the flattened namespace-member name. Direct vLLM and gateway +blocking use `store: false` full-item replay; gateway SSE/WebSocket profiles use stored continuation. The private +projection is not the gateway-to-vLLM envelope. + +Each profile also records a four-entry `tool_choice` sequence so inference cannot repeat a prior call or emit another +call on the final turn. OpenAI uses `required`, selected `get_weather`, `auto`, then `none`: its function selector +cannot identify a function nested in a namespace, and the stored continuation intentionally omits the repeated `tools` +parameter required by `required`, so the third-turn prompt identifies `travel.get_timezone` and the characterization +strictly rejects a wrong or multiple call. Gateway profiles use `required`, selected `get_weather`, +selected public `travel.get_timezone`, then `none`, so the gateway can resolve the namespace member to its flattened +upstream identity. Direct vLLM selects the synthetic `tool_search`, `get_weather`, that flattened namespace member, +then `none`. The first public choice is `required` because the gateway's typed public `tool_choice` currently has no +`type: "tool_search"` selector; deferred declarations leave tool search as the only available choice on that turn. The complete set is exactly seven flows: OpenAI blocking/SSE, direct-vLLM blocking/SSE, and gateway blocking/SSE/WS. HTTP uses the embedded proxy; WebSocket uses bounded direct capture. Recorded vLLM was `0.25.1`; the target must expose the Responses API with a compatible function-call parser, but `/version` did not expose exact flags. Use a fresh gateway -database. +database. After recording, the script runs `tool_search_characterization_test` as the single semantic validator for the +matrix. Start the gateway with this SQLite path absent: diff --git a/crates/agentic-server-core/tests/cassettes/record_cassette.py b/crates/agentic-server-core/tests/cassettes/record_cassette.py index 76a98588..cf64f585 100644 --- a/crates/agentic-server-core/tests/cassettes/record_cassette.py +++ b/crates/agentic-server-core/tests/cassettes/record_cassette.py @@ -41,17 +41,14 @@ import secrets import socket import ssl -import stat import struct import sys -import tempfile import threading import time from contextlib import asynccontextmanager -from dataclasses import dataclass from pathlib import Path from typing import Any, AsyncGenerator -from urllib.parse import parse_qsl, urlparse +from urllib.parse import urlparse import click import httpx @@ -69,12 +66,6 @@ PROXY_HOST = "127.0.0.1" PROXY_PORT = 7070 TIMEOUT = 60 * 5 -MAX_WEBSOCKET_HANDSHAKE_BYTES = 64 * 1024 -MAX_WEBSOCKET_FRAME_BYTES = 16 * 1024 * 1024 -MAX_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024 -MAX_WEBSOCKET_MESSAGE_FRAMES = 10_000 -MAX_WEBSOCKET_CAPTURE_BYTES = 64 * 1024 * 1024 -MAX_WEBSOCKET_CAPTURE_MESSAGES = 10_000 EXCLUDED_RESPONSE_HEADERS = { "content-encoding", @@ -91,63 +82,6 @@ "x-run-id", } -SENSITIVE_FIELD_NAMES = { - "access_token", - "api_key", - "apikey", - "auth_token", - "authorization", - "credential", - "cookie", - "client_secret", - "password", - "proxy_authorization", - "refresh_token", - "secret", - "set_cookie", - "sig", - "signature", - "token", - "x_auth_token", - "x_api_key", - "x_amz_credential", - "x_amz_signature", - "x_goog_credential", - "x_goog_signature", -} - -SENSITIVE_ENV_NAME_PARTS = ( - "API_KEY", - "AUTHORIZATION", - "BEARER", - "COOKIE", - "CREDENTIAL", - "PASSWORD", - "PRIVATE_KEY", - "SECRET", - "TOKEN", -) - - -class SecretRecordingError(ValueError): - """Raised before a cassette write could persist sensitive material.""" - - -@dataclass(frozen=True) -class ToolContinuation: - """Input items for one client-tool continuation step.""" - - input_items: list[dict] - loaded_search_tools: bool - - -@dataclass(frozen=True) -class ProxyHandle: - """Owned recorder proxy lifecycle.""" - - server: uvicorn.Server - thread: threading.Thread - def _mask_authorization(value: str) -> str: if not value: @@ -172,159 +106,6 @@ def _filter_response_headers(headers) -> dict: } -def _normalized_sensitive_name(name: object) -> str: - return str(name).strip().lower().replace("-", "_") - - -def _is_masked_secret(value: object) -> bool: - return isinstance(value, str) and value.strip() in {"***", "Bearer ***"} - - -def _has_secret_material(value: object) -> bool: - if value is None or value is False: - return False - if _is_masked_secret(value): - return False - if isinstance(value, str): - return bool(value.strip()) - # Containers are traversed recursively. This keeps JSON Schema properties - # named `password` or `token` recordable while still rejecting actual values. - return not isinstance(value, (list, tuple, set, dict)) - - -def _sensitive_environment_values(environment: dict[str, str]) -> tuple[str, ...]: - values = { - value - for name, value in environment.items() - if value - and len(value) >= 8 - and any(part in name.upper() for part in SENSITIVE_ENV_NAME_PARTS) - and not _is_masked_secret(value) - } - return tuple(sorted(values, key=len, reverse=True)) - - -def _reject_sensitive_url(value: str, path: str) -> None: - parsed = urlparse(value) - if parsed.scheme not in {"http", "https", "ws", "wss"}: - return - if parsed.username or parsed.password: - raise SecretRecordingError(f"refusing to record URL credentials at {path}") - for query_name, query_value in parse_qsl(parsed.query, keep_blank_values=True): - if ( - _normalized_sensitive_name(query_name) in SENSITIVE_FIELD_NAMES - and _has_secret_material(query_value) - ): - raise SecretRecordingError(f"refusing to record URL query credentials at {path}") - - -def _has_nonempty_header_value(value: object) -> bool: - if value is None or value is False: - return False - if isinstance(value, str): - return bool(value.strip()) - if isinstance(value, (list, tuple, set, dict)): - return bool(value) - return True - - -def _reject_mcp_credentials(value: dict, path: str) -> None: - if value.get("type") != "mcp": - return - headers = value.get("headers") - if isinstance(headers, dict) and any( - _has_nonempty_header_value(header_value) - for header_value in headers.values() - ): - raise SecretRecordingError( - f"refusing to record non-empty MCP headers at {path}.headers" - ) - - server_url = value.get("server_url") - if not isinstance(server_url, str) or not server_url: - return - parsed = urlparse(server_url) - if parsed.username or parsed.password or parsed.query: - raise SecretRecordingError( - f"refusing to record MCP server_url credentials or query at {path}.server_url" - ) - - -def _reject_sensitive_value( - value: object, - *, - path: str, - environment_values: tuple[str, ...], -) -> None: - if isinstance(value, dict): - _reject_mcp_credentials(value, path) - for raw_name, nested in value.items(): - name = _normalized_sensitive_name(raw_name) - nested_path = f"{path}.{raw_name}" if path else str(raw_name) - if name in SENSITIVE_FIELD_NAMES and _has_secret_material(nested): - raise SecretRecordingError(f"refusing to record sensitive field at {nested_path}") - _reject_sensitive_value( - nested, - path=nested_path, - environment_values=environment_values, - ) - return - if isinstance(value, (list, tuple)): - for index, nested in enumerate(value): - _reject_sensitive_value( - nested, - path=f"{path}[{index}]", - environment_values=environment_values, - ) - return - if not isinstance(value, str): - return - - for secret in environment_values: - if secret in value: - raise SecretRecordingError(f"refusing to record an environment secret at {path}") - stripped = value.strip() - if stripped.startswith(("{", "[")): - try: - decoded = json.loads(stripped) - except json.JSONDecodeError: - decoded = None - if isinstance(decoded, (dict, list)): - _reject_sensitive_value( - decoded, - path=f"{path}.json", - environment_values=environment_values, - ) - _reject_sensitive_url(value, path) - - -def _prepare_turn_for_write( - turn: dict[str, Any], - *, - environment: dict[str, str] | None = None, -) -> dict[str, Any]: - """Mask envelope authorization headers, then reject all other secrets. - - Request and response bodies remain byte-for-byte semantically intact. Sensitive - values nested in bodies, query parameters, provider errors, or environment-derived - strings fail the recording instead of being silently rewritten. - """ - prepared = copy.deepcopy(turn) - for side in ("request", "response"): - headers = prepared.get(side, {}).get("headers") - if not isinstance(headers, dict): - continue - for name, value in list(headers.items()): - if str(name).lower() == "authorization": - headers[name] = _mask_authorization(str(value)) - - environment_values = _sensitive_environment_values( - dict(os.environ) if environment is None else environment - ) - _reject_sensitive_value(prepared, path="turn", environment_values=environment_values) - return prepared - - def _turn_number(output_file: Path) -> int: if not output_file.exists(): return 1 @@ -337,51 +118,17 @@ def _turn_number(output_file: Path) -> int: return len(data["turns"]) + 1 -def _append_turn( - output_file: Path, - turn: dict[str, Any], - *, - environment: dict[str, str] | None = None, -) -> None: - prepared_turn = _prepare_turn_for_write(turn, environment=environment) - existing_mode = ( - stat.S_IMODE(output_file.stat().st_mode) if output_file.exists() else None - ) +def _append_turn(output_file: Path, turn: dict[str, Any]) -> None: + output_file.parent.mkdir(parents=True, exist_ok=True) if output_file.exists() and output_file.stat().st_size > 0: data = yaml_load(output_file.read_text(encoding="utf-8")) or {} else: data = {} turns: list = data.get("turns", []) - turns.append(prepared_turn) + turns.append(turn) data["turns"] = turns - _reject_sensitive_value( - data, - path="cassette", - environment_values=_sensitive_environment_values( - dict(os.environ) if environment is None else environment - ), - ) - - output_file.parent.mkdir(parents=True, exist_ok=True) - temporary_path: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=output_file.parent, - prefix=f".{output_file.name}.", - delete=False, - ) as temporary: - temporary_path = Path(temporary.name) - yaml_dump(data, temporary, allow_unicode=True, default_flow_style=False) - temporary.flush() - os.fsync(temporary.fileno()) - if existing_mode is not None: - os.chmod(temporary_path, existing_mode) - os.replace(temporary_path, output_file) - finally: - if temporary_path is not None and temporary_path.exists(): - temporary_path.unlink() + with open(output_file, "w", encoding="utf-8") as f: + yaml_dump(data, f, allow_unicode=True, default_flow_style=False) @asynccontextmanager @@ -507,7 +254,7 @@ async def _stream() -> AsyncGenerator[str, None]: # ── proxy lifecycle ─────────────────────────────────────────────────────────── -def _start_proxy(output_file: Path, target_host: str, port: int) -> ProxyHandle: +def _start_proxy(output_file: Path, target_host: str, port: int) -> uvicorn.Server: output_file.parent.mkdir(parents=True, exist_ok=True) output_file.write_text("", encoding="utf-8") proxy_app.state.output_file = output_file @@ -519,26 +266,20 @@ def _start_proxy(output_file: Path, target_host: str, port: int) -> ProxyHandle: thread = threading.Thread(target=server.run, daemon=True) thread.start() + # TCP-only readiness check — no HTTP request forwarded to upstream for _ in range(40): - if server.started and thread.is_alive(): - return ProxyHandle(server=server, thread=thread) - if not thread.is_alive(): - break - time.sleep(0.3) + try: + with socket.create_connection((PROXY_HOST, port), timeout=0.3): + break + except OSError: + time.sleep(0.3) - server.should_exit = True - thread.join(timeout=2) - raise RuntimeError(f"recorder proxy failed to own {PROXY_HOST}:{port}") + return server -def _stop_proxy(handle: ProxyHandle) -> None: - handle.server.should_exit = True - handle.thread.join(timeout=5) - if handle.thread.is_alive(): - handle.server.force_exit = True - handle.thread.join(timeout=2) - if handle.thread.is_alive(): - raise RuntimeError("recorder proxy did not stop within the bounded shutdown window") +def _stop_proxy(server: uvicorn.Server) -> None: + server.should_exit = True + time.sleep(0.5) def _create_conversation(client: httpx.Client, proxy_url: str) -> str: @@ -549,16 +290,12 @@ def _create_conversation(client: httpx.Client, proxy_url: str) -> str: return conv_id -def _send_nonstreaming( - client: httpx.Client, - body: dict, - proxy_url: str, -) -> dict | None: +def _send_nonstreaming(client: httpx.Client, body: dict, proxy_url: str) -> dict | None: resp = client.post(f"{proxy_url}/v1/responses", json=body, timeout=300) - data = resp.json() resp.raise_for_status() + data = resp.json() print(f"\n[Response]\n{json.dumps(data, indent=2)}\n") - return data if isinstance(data, dict) else None + return data def _send_streaming(client: httpx.Client, body: dict, proxy_url: str) -> dict | None: @@ -730,8 +467,6 @@ def _read_http_response(self) -> str: if not chunk: raise EOFError("websocket closed during handshake") data.extend(chunk) - if len(data) > MAX_WEBSOCKET_HANDSHAKE_BYTES: - raise ValueError("websocket handshake exceeded the recording limit") header_end = data.index(b"\r\n\r\n") + 4 self._receive_buffer.extend(data[header_end:]) return data[:header_end].decode("iso-8859-1") @@ -765,11 +500,7 @@ def _send_frame(self, opcode: int, payload: bytes) -> None: def receive_text(self) -> str | None: message = bytearray() - frame_count = 0 while True: - frame_count += 1 - if frame_count > MAX_WEBSOCKET_MESSAGE_FRAMES: - raise ValueError("websocket message exceeded the frame-count recording limit") first, second = self._read_exact(2) fin = bool(first & 0x80) opcode = first & 0x0F @@ -779,10 +510,6 @@ def receive_text(self) -> str | None: length = struct.unpack("!H", self._read_exact(2))[0] elif length == 127: length = struct.unpack("!Q", self._read_exact(8))[0] - if length > MAX_WEBSOCKET_FRAME_BYTES: - raise ValueError("websocket frame exceeded the recording limit") - if len(message) + length > MAX_WEBSOCKET_MESSAGE_BYTES: - raise ValueError("websocket message exceeded the recording limit") mask = self._read_exact(4) if masked else b"" payload = self._read_exact(length) if masked: @@ -858,8 +585,6 @@ def _send_websocket( } response_data = None - captured_bytes = 0 - captured_messages = 0 print("\n[WebSocket response]") with WebSocketClient(websocket_url, headers) as ws: ws.send_text(json.dumps(wire_body, separators=(",", ":"))) @@ -867,12 +592,6 @@ def _send_websocket( message = ws.receive_text() if message is None: break - captured_messages += 1 - captured_bytes += len(message.encode("utf-8")) - if captured_messages > MAX_WEBSOCKET_CAPTURE_MESSAGES: - raise ValueError("websocket capture exceeded the message-count limit") - if captured_bytes > MAX_WEBSOCKET_CAPTURE_BYTES: - raise ValueError("websocket capture exceeded the byte recording limit") print(message) turn["response"]["websocket"].append(message) try: @@ -948,43 +667,36 @@ def _inject_tools(body: dict, tools: list | None, tool_choice: Any) -> None: def _extract_tool_calls(response_data: dict | None) -> list[dict]: - """Extract client-owned tool calls and reject unusable call linkage.""" + """Extract client-owned tool calls from a Responses output.""" if not response_data: return [] output = response_data.get("output", []) - tool_calls = [ + return [ item for item in output if item.get("type") in {"function_call", "custom_tool_call", "tool_search_call"} ] - for call in tool_calls: - call_id = call.get("call_id") - if not isinstance(call_id, str) or not call_id.strip(): - raise ValueError( - f"{call.get('type', 'tool')} item has no non-empty call_id" - ) - return tool_calls - - -def _canonical_tool_search_output(tools: list[dict]) -> str: - return json.dumps( - {"tools": tools}, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) -def _build_tool_continuation( +def _build_tool_output_input( tool_calls: list[dict], tool_outputs: dict[str, str], - tool_search_tools: list[dict] | None, user_prompt: str | None, -) -> ToolContinuation: - """Project one semantic client-tool transition onto public or normalized wire.""" + tool_search_tools: list[dict] | None = None, +) -> list[dict]: + """Build tool output items followed by an optional user message. + + Args: + tool_calls: client-owned call items from the previous response. + tool_outputs: mapping of tool name -> fake JSON output string. + user_prompt: the next user message (None for tool-output-only turns). + tool_search_tools: tools returned for public or synthetic tool search. + + Returns: + A list suitable for the `input` field of the next request. + """ input_items: list[dict] = [] - loaded_search_tools = False for call in tool_calls: call_id = call.get("call_id") if not isinstance(call_id, str) or not call_id.strip(): @@ -995,13 +707,10 @@ def _build_tool_continuation( call_type = call.get("type") name = call.get("name", "") is_public_search = call_type == "tool_search_call" - is_normalized_search = call_type == "function_call" and name == "tool_search" - if is_public_search or is_normalized_search: + is_synthetic_search = call_type == "function_call" and name == "tool_search" + if is_public_search or is_synthetic_search: if tool_search_tools is None: - raise ValueError( - "a tool-search call requires --tool-search-output-tools" - ) - loaded_search_tools = True + raise ValueError("a tool-search call requires --tool-search-output-tools") if is_public_search: input_items.append( { @@ -1017,7 +726,12 @@ def _build_tool_continuation( { "type": "function_call_output", "call_id": call_id, - "output": _canonical_tool_search_output(tool_search_tools), + "output": json.dumps( + {"tools": tool_search_tools}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ), } ) continue @@ -1026,9 +740,6 @@ def _build_tool_continuation( raise ValueError( f"loaded function {name!r} requires an explicit output fixture" ) - output = tool_outputs.get( - name, json.dumps({"result": f"mock output for {name}"}) - ) input_items.append( { "type": ( @@ -1037,125 +748,17 @@ def _build_tool_continuation( else "function_call_output" ), "call_id": call_id, - "output": output, + "output": tool_outputs.get( + name, json.dumps({"result": f"mock output for {name}"}) + ), } ) if user_prompt: input_items.append( - { - "type": "message", - "role": "user", - "content": user_prompt, - } + {"type": "message", "role": "user", "content": user_prompt} ) - return ToolContinuation( - input_items=input_items, - loaded_search_tools=loaded_search_tools, - ) - - -def _is_tool_search_call(call: dict) -> bool: - return call.get("type") == "tool_search_call" or ( - call.get("type") == "function_call" and call.get("name") == "tool_search" - ) - - -def _validate_tool_search_turn_calls( - turn: int, - calls: list[dict], - returned_tools: list[dict], -) -> None: - if turn == 2: - if len(calls) != 1 or not _is_tool_search_call(calls[0]): - raise ValueError( - "tool-search turn one must emit exactly one search call" - ) - call = calls[0] - arguments = call.get("arguments") - if call.get("type") == "tool_search_call": - if call.get("execution") != "client" or call.get("status") != "completed": - raise ValueError( - "public tool-search call must be explicitly client/completed" - ) - if not isinstance(arguments, dict) or not arguments: - raise ValueError( - "public tool-search arguments must be a non-empty object" - ) - query = arguments.get("query") - else: - if call.get("status") != "completed": - raise ValueError( - "normalized tool-search call must be explicitly completed" - ) - if not isinstance(arguments, str): - raise ValueError( - "normalized tool-search arguments must be JSON text" - ) - try: - decoded = json.loads(arguments) - except json.JSONDecodeError as error: - raise ValueError( - "normalized tool-search arguments must be valid JSON" - ) from error - if not isinstance(decoded, dict) or not decoded: - raise ValueError( - "normalized tool-search arguments must be a non-empty object" - ) - query = decoded.get("query") - if not isinstance(query, str) or not query.strip(): - raise ValueError("tool-search arguments must contain a non-empty query") - return - if turn != 3: - return - - returned_function_names = { - tool.get("name") - for tool in returned_tools - if tool.get("type") == "function" and isinstance(tool.get("name"), str) - } - if ( - len(calls) != 1 - or calls[0].get("type") != "function_call" - or calls[0].get("name") not in returned_function_names - ): - raise ValueError( - "tool-search turn two must emit exactly one loaded function call" - ) - if calls[0].get("status") != "completed": - raise ValueError("loaded function call must be explicitly completed") - arguments = calls[0].get("arguments") - if not isinstance(arguments, str): - raise ValueError("loaded function arguments must be JSON text") - try: - decoded = json.loads(arguments) - except json.JSONDecodeError as error: - raise ValueError("loaded function arguments must be valid JSON") from error - if not isinstance(decoded, dict): - raise ValueError("loaded function arguments must decode to an object") - - -def _build_tool_output_input( - tool_calls: list[dict], - tool_outputs: dict[str, str], - user_prompt: str | None, -) -> list[dict]: - """Build tool output items followed by an optional user message. - - Args: - tool_calls: function_call or custom_tool_call items from the previous response. - tool_outputs: mapping of tool name -> fake JSON output string. - user_prompt: the next user message (None for tool-output-only turns). - - Returns: - A list suitable for the `input` field of the next request. - """ - return _build_tool_continuation( - tool_calls, - tool_outputs, - None, - user_prompt, - ).input_items + return input_items def run_conv( @@ -1375,6 +978,7 @@ def run_responses( output_file: Path | None = None, tools: list | None = None, tool_choice: Any = None, + tool_choice_sequence: list[Any] | None = None, tool_outputs: dict[str, str] | None = None, tool_search_output_tools: list[dict] | None = None, tools_after_search: list | None = None, @@ -1382,25 +986,6 @@ def run_responses( preset_input: str | list | None = None, manual_item_replay: bool = False, ) -> None: - tool_search_recording = ( - tool_search_output_tools is not None or tools_after_search is not None - ) - if tool_search_recording and turns != 3: - raise click.UsageError( - "tool-search recorder fixtures require three Responses turns" - ) - if manual_item_replay and (not tool_search_recording or store or preset_input is not None): - raise click.UsageError( - "manual item replay requires store=false tool-search recording without preset input" - ) - if tool_search_recording and not store and not manual_item_replay: - raise click.UsageError( - "store=false tool-search recording requires manual item replay" - ) - if tool_search_recording and branches: - raise click.UsageError( - "tool-search recorder fixtures do not support response branching" - ) response_ids: dict[int, str] = {} responses: dict[int, dict] = {} branch_map: dict[int, int] = {} @@ -1440,22 +1025,20 @@ def run_responses( pending_calls = ( _extract_tool_calls(last_response) if has_output_fixtures else [] ) - if tool_search_recording: - _validate_tool_search_turn_calls( - turn, - pending_calls, - tool_search_output_tools or [], - ) if pending_calls: - continuation = _build_tool_continuation( + search_tools_loaded = search_tools_loaded or any( + call.get("type") == "tool_search_call" + or ( + call.get("type") == "function_call" + and call.get("name") == "tool_search" + ) + for call in pending_calls + ) + input_value = _build_tool_output_input( pending_calls, tool_outputs or {}, - tool_search_output_tools, prompt if prompt else None, - ) - input_value = continuation.input_items - search_tools_loaded = ( - search_tools_loaded or continuation.loaded_search_tools + tool_search_output_tools, ) click.echo( f" [injecting {len(pending_calls)} tool output(s) before user message]" @@ -1487,7 +1070,8 @@ def run_responses( if tool_search_output_tools is not None: body["parallel_tool_calls"] = False effective_tools = tools_after_search if search_tools_loaded else tools - _inject_tools(body, effective_tools, tool_choice) + turn_tool_choice = tool_choice_sequence[turn - 1] if tool_choice_sequence is not None else tool_choice + _inject_tools(body, effective_tools, turn_tool_choice) response_data = _send( client, body, @@ -1506,12 +1090,6 @@ def run_responses( "manual item replay requires every response to contain an output array" ) manual_history.extend(copy.deepcopy(response_output)) - if tool_search_recording and turn == turns: - final_calls = _extract_tool_calls(response_data) - if final_calls: - raise ValueError( - "tool-search final response must not contain client tool calls" - ) previous_response_id = response_id if store else None last_response = response_data if response_id: @@ -1659,6 +1237,14 @@ def run_responses( default=None, help='tool_choice value: "auto", "none", "required", or JSON e.g. \'{"type":"function","name":"foo"}\'.', ) +@click.option( + "--tool-choice-sequence", + "tool_choice_sequence_file", + metavar="FILE", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="JSON array containing one tool_choice value per linear Responses turn.", +) @click.option( "--tool-outputs", "tool_outputs_file", @@ -1719,6 +1305,7 @@ def main( gateway_url: str | None, tools_file: str | None, tool_choice_raw: str | None, + tool_choice_sequence_file: str | None, tool_outputs_file: str | None, tool_search_output_tools_file: str | None, tools_after_search_file: str | None, @@ -1746,14 +1333,18 @@ def main( raise click.UsageError( "--tools-after-search requires --tool-search-output-tools." ) + if manual_item_replay and not tool_search_recording: + raise click.UsageError( + "--manual-item-replay requires --tool-search-output-tools." + ) if tool_search_recording: if mode != "responses": raise click.UsageError( "tool-search recorder fixtures require --mode responses." ) - if turns != 3: + if turns != 4: raise click.UsageError( - "tool-search recorder fixtures require exactly --turns 3." + "tool-search recorder fixtures require exactly --turns 4." ) if branches: raise click.UsageError( @@ -1775,6 +1366,18 @@ def main( raise click.UsageError( "tool-search recording requires --tools and --tool-outputs." ) + if not tool_choice_sequence_file: + raise click.UsageError( + "tool-search recording requires --tool-choice-sequence." + ) + if tool_choice_raw and tool_choice_sequence_file: + raise click.UsageError( + "--tool-choice and --tool-choice-sequence are mutually exclusive." + ) + if tool_choice_sequence_file and (mode != "responses" or branches): + raise click.UsageError( + "--tool-choice-sequence requires linear --mode responses without branches." + ) backend_count = sum(bool(url) for url in (openai_url, vllm_url, gateway_url)) if backend_count > 1: raise click.UsageError("--openai, --vllm, and --gateway are mutually exclusive.") @@ -1809,6 +1412,15 @@ def main( else: tool_choice = stripped + tool_choice_sequence: list[Any] | None = None + if tool_choice_sequence_file: + with open(tool_choice_sequence_file, encoding="utf-8") as f: + tool_choice_sequence = json.load(f) + if not isinstance(tool_choice_sequence, list) or len(tool_choice_sequence) != turns: + raise click.UsageError( + "--tool-choice-sequence must contain one JSON value per turn." + ) + tool_outputs: dict[str, str] | None = None if tool_outputs_file: with open(tool_outputs_file, encoding="utf-8") as f: @@ -1872,8 +1484,6 @@ def main( headers = {"Authorization": f"Bearer {api_key}"} backend_label = f"OpenAI: {target}" - _reject_sensitive_url(target, "upstream URL") - output_file = Path(output).resolve() proxy_url = f"http://{PROXY_HOST}:{proxy_port}" store = not no_store @@ -1907,6 +1517,7 @@ def main( output_file, tools=tools, tool_choice=tool_choice, + tool_choice_sequence=tool_choice_sequence, tool_outputs=tool_outputs, tool_search_output_tools=tool_search_output_tools, tools_after_search=tools_after_search, @@ -1916,7 +1527,7 @@ def main( ) else: click.echo(f"Proxy: {proxy_url} (requests go through here for recording)") - proxy = _start_proxy(output_file, target, proxy_port) + server = _start_proxy(output_file, target, proxy_port) click.echo(f"Proxy ready on {proxy_url}\n") try: @@ -1942,6 +1553,7 @@ def main( output_file, tools=tools, tool_choice=tool_choice, + tool_choice_sequence=tool_choice_sequence, tool_outputs=tool_outputs, tool_search_output_tools=tool_search_output_tools, tools_after_search=tools_after_search, @@ -1964,7 +1576,7 @@ def main( elif mode == "store_true_then_store_false": run_store_true_then_store_false(client, turns, model, stream, proxy_url) finally: - _stop_proxy(proxy) + _stop_proxy(server) click.echo(f"\nAll turns recorded -> {output_file}") diff --git a/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh b/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh index 3985b624..36b1cdd4 100755 --- a/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh +++ b/crates/agentic-server-core/tests/cassettes/record_tool_search_cassettes.sh @@ -24,6 +24,9 @@ PROMPTS="$BASE_DIR/prompts.txt" OPENAI_TOOLS="$BASE_DIR/openai_tools.json" VLLM_INITIAL_TOOLS="$BASE_DIR/vllm_initial_tools.json" VLLM_NEXT_TOOLS="$BASE_DIR/vllm_tools_after_search.json" +OPENAI_TOOL_CHOICES="$BASE_DIR/openai_tool_choice_sequence.json" +GATEWAY_TOOL_CHOICES="$BASE_DIR/gateway_tool_choice_sequence.json" +VLLM_TOOL_CHOICES="$BASE_DIR/vllm_tool_choice_sequence.json" OPENAI_MODEL="${OPENAI_MODEL:-gpt-5.6}" VLLM_MODEL="${MODEL:-Qwen/Qwen3.6-35B-A3B-FP8}" VLLM_URL="${VLLM_URL:-}" @@ -35,231 +38,6 @@ model_slug() { printf '%s' "$1" | tr '/: ' '---' } -validate_recording() { - local cassette="$1" - local projection="$2" - local initial_tools="$3" - local next_tools="$4" - - python - "$cassette" "$projection" "$RETURNED_TOOLS" "$initial_tools" "$next_tools" <<'PY' -import json -import sys -from pathlib import Path - -import yaml - -path = Path(sys.argv[1]) -projection = sys.argv[2] -expected_returned_tools = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) -expected_initial_tools = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8")) -expected_next_tools = json.loads(Path(sys.argv[5]).read_text(encoding="utf-8")) if sys.argv[5] else None -document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} -turns = document.get("turns") or [] -if len(turns) != 3: - raise SystemExit(f"ERROR: expected three recorded turns in {path}, found {len(turns)}") - - -def terminal_response(turn): - response = turn.get("response") or {} - websocket = (turn.get("request") or {}).get("transport") == "websocket" - expected_status = 101 if websocket else 200 - if response.get("status_code") != expected_status: - raise SystemExit(f"ERROR: recording returned HTTP {response.get('status_code')}: {response.get('body')}") - if isinstance(response.get("body"), dict): - return response["body"] - for raw in response.get("sse") or []: - for line in raw.splitlines(): - if not line.startswith("data: ") or line == "data: [DONE]": - continue - event = json.loads(line.removeprefix("data: ")) - if event.get("type") == "response.completed": - return event.get("response") or {} - if event.get("type") in {"error", "response.failed"}: - raise SystemExit(f"ERROR: streaming recording failed: {event}") - raise SystemExit("ERROR: streaming recording has no response.completed event") - - -responses = [terminal_response(turn) for turn in turns] -outputs = [response.get("output") or [] for response in responses] -search_type = "function_call" if projection == "normalized" else "tool_search_call" -first_calls = [item for item in outputs[0] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] -search_calls = [ - item - for item in outputs[0] - if item.get("type") == search_type - and (projection != "normalized" or item.get("name") == "tool_search") -] -if len(first_calls) != 1 or len(search_calls) != 1: - raise SystemExit(f"ERROR: expected one {projection} search call, found {search_calls}") -search_arguments = search_calls[0].get("arguments") -if projection != "normalized": - if search_calls[0].get("execution") != "client" or search_calls[0].get("status") != "completed": - raise SystemExit(f"ERROR: public search call must be explicitly client/completed: {search_calls[0]}") - if not isinstance(search_arguments, dict) or not search_arguments.get("query"): - raise SystemExit(f"ERROR: public search arguments must be a non-empty query object: {search_arguments}") -else: - if search_calls[0].get("status") != "completed": - raise SystemExit(f"ERROR: normalized search call must be explicitly completed: {search_calls[0]}") - try: - normalized_arguments = json.loads(search_arguments) - except (TypeError, json.JSONDecodeError) as error: - raise SystemExit(f"ERROR: normalized search arguments are invalid: {search_arguments}") from error - if not isinstance(normalized_arguments, dict) or not normalized_arguments.get("query"): - raise SystemExit(f"ERROR: normalized search arguments must contain a query: {normalized_arguments}") - -first_stream = (turns[0].get("response") or {}).get("sse") or [] -if projection != "normalized" and first_stream: - events = [] - for raw in first_stream: - for line in raw.splitlines(): - if line.startswith("data: ") and line != "data: [DONE]": - events.append(json.loads(line.removeprefix("data: "))) - sequence_numbers = [event.get("sequence_number") for event in events] - if sequence_numbers != list(range(len(events))): - raise SystemExit(f"ERROR: public stream sequence numbers are not contiguous: {sequence_numbers}") - if any(event.get("type") in {"response.function_call_arguments.delta", "response.function_call_arguments.done"} for event in events): - raise SystemExit("ERROR: public search stream leaked normalized function argument events") - if any( - event.get("type") in {"response.output_item.added", "response.output_item.done"} - and (event.get("item") or {}).get("type") == "function_call" - and (event.get("item") or {}).get("name") == "tool_search" - for event in events - ): - raise SystemExit("ERROR: public search stream leaked a normalized synthetic function item") - if any( - tool.get("type") == "function" and tool.get("name") == "tool_search" - for event in events - for tool in ((event.get("response") or {}).get("tools") or []) - ): - raise SystemExit("ERROR: public search stream leaked the private synthetic declaration") - lifecycle = [ - event - for event in events - if event.get("type") in {"response.output_item.added", "response.output_item.done"} - and (event.get("item") or {}).get("type") == "tool_search_call" - ] - if [event.get("type") for event in lifecycle] != ["response.output_item.added", "response.output_item.done"]: - raise SystemExit(f"ERROR: public search lifecycle is incomplete or reordered: {lifecycle}") - added = lifecycle[0].get("item") or {} - done = lifecycle[1].get("item") or {} - if added.get("status") != "in_progress" or added.get("arguments") != {}: - raise SystemExit(f"ERROR: invalid public search added item: {added}") - if done.get("status") != "completed" or done.get("arguments") != search_arguments: - raise SystemExit(f"ERROR: invalid public search done item: {done}") - if ( - added.get("id") != done.get("id") - or added.get("call_id") != done.get("call_id") - or lifecycle[0].get("output_index") != lifecycle[1].get("output_index") - ): - raise SystemExit("ERROR: public search lifecycle changed item/call identity") - if done not in outputs[0]: - raise SystemExit("ERROR: terminal response output differs from public search done item") - -second_calls = [item for item in outputs[1] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] -loaded_calls = [ - item - for item in outputs[1] - if item.get("type") == "function_call" and item.get("name") == "get_weather" -] -if len(second_calls) != 1 or len(loaded_calls) != 1: - raise SystemExit(f"ERROR: expected one loaded get_weather call, found {loaded_calls}") -if loaded_calls[0].get("status") != "completed": - raise SystemExit(f"ERROR: loaded get_weather call must be explicitly completed: {loaded_calls[0]}") -try: - loaded_arguments = json.loads(loaded_calls[0].get("arguments") or "null") -except json.JSONDecodeError as error: - raise SystemExit("ERROR: loaded function arguments are not valid JSON") from error -if loaded_arguments != {"city": "Paris"}: - raise SystemExit(f"ERROR: loaded function arguments did not equal city=Paris: {loaded_arguments}") - -turn_two_input = (turns[1].get("request") or {}).get("body", {}).get("input") or [] -turn_three_input = (turns[2].get("request") or {}).get("body", {}).get("input") or [] -search_output_type = "function_call_output" if projection == "normalized" else "tool_search_output" -search_outputs = [ - item - for item in turn_two_input - if item.get("type") == search_output_type and item.get("call_id") == search_calls[0].get("call_id") -] -function_outputs = [ - item - for item in turn_three_input - if item.get("type") == "function_call_output" and item.get("call_id") == loaded_calls[0].get("call_id") -] -if len(search_outputs) != 1 or len(function_outputs) != 1: - raise SystemExit("ERROR: recorded continuation call IDs do not link to their preceding calls") -if projection != "normalized": - if ( - search_outputs[0].get("type") != "tool_search_output" - or search_outputs[0].get("execution") != "client" - or search_outputs[0].get("status") != "completed" - or search_outputs[0].get("tools") != expected_returned_tools - ): - raise SystemExit(f"ERROR: invalid public search output: {search_outputs[0]}") -else: - if search_outputs[0].get("type") != "function_call_output": - raise SystemExit(f"ERROR: invalid normalized search output: {search_outputs[0]}") - raw_normalized_output = search_outputs[0].get("output") or "" - normalized_output = json.loads(raw_normalized_output or "null") - expected_canonical_output = json.dumps( - {"tools": expected_returned_tools}, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) - if raw_normalized_output != expected_canonical_output: - raise SystemExit("ERROR: normalized search output is not canonical") - if normalized_output != {"tools": expected_returned_tools}: - raise SystemExit(f"ERROR: normalized search output has no tools: {normalized_output}") -if function_outputs[0].get("type") != "function_call_output": - raise SystemExit(f"ERROR: invalid loaded function output: {function_outputs[0]}") - -messages = [item for item in outputs[2] if item.get("type") == "message"] -final_calls = [item for item in outputs[2] if item.get("type") in {"tool_search_call", "function_call", "custom_tool_call"}] -if final_calls: - raise SystemExit(f"ERROR: final response contained tool calls: {final_calls}") -text = "".join( - part.get("text", "") - for message in messages - for part in message.get("content") or [] - if part.get("type") == "output_text" -) -if text.strip() != "PARIS_WEATHER_OK": - raise SystemExit(f"ERROR: final response text did not match: {text!r}") - -request_bodies = [(turn.get("request") or {}).get("body") or {} for turn in turns] -if request_bodies[0].get("tools") != expected_initial_tools: - raise SystemExit("ERROR: first turn tools differ from the initial fixture") -if projection == "public-stored": - if not all(body.get("store") is True for body in request_bodies): - raise SystemExit("ERROR: public characterization must use stored continuation") - if request_bodies[1].get("previous_response_id") != responses[0].get("id"): - raise SystemExit("ERROR: public turn two does not continue turn one") - if request_bodies[2].get("previous_response_id") != responses[1].get("id"): - raise SystemExit("ERROR: public turn three does not continue turn two") - if "tools" in request_bodies[1] or "tools" in request_bodies[2]: - raise SystemExit("ERROR: public continuation must omit top-level tools after search") -else: - if not all(body.get("store") is False for body in request_bodies): - raise SystemExit("ERROR: manual tool-search replay must use store=false") - if any("previous_response_id" in body for body in request_bodies): - raise SystemExit("ERROR: manual tool-search replay must omit previous_response_id") - if projection == "normalized": - if request_bodies[1].get("tools") != expected_next_tools or request_bodies[2].get("tools") != expected_next_tools: - raise SystemExit("ERROR: direct-vLLM continuation did not retain post-search tools") - elif "tools" in request_bodies[1] or "tools" in request_bodies[2]: - raise SystemExit("ERROR: gateway manual replay must omit top-level tools after search") - first_input = request_bodies[0].get("input") or [] - second_input = request_bodies[1].get("input") or [] - third_input = request_bodies[2].get("input") or [] - expected_second_prefix = first_input + outputs[0] - expected_third_prefix = second_input + outputs[1] - if second_input[: len(expected_second_prefix)] != expected_second_prefix: - raise SystemExit("ERROR: manual turn two does not replay full turn-one item history") - if third_input[: len(expected_third_prefix)] != expected_third_prefix: - raise SystemExit("ERROR: manual turn three does not replay full prior item history") -PY -} - record_scenario() { local endpoint_flag="$1" local endpoint="$2" @@ -267,28 +45,28 @@ record_scenario() { local tools="$4" local next_tools="$5" local projection="$6" - local output="$7" - local recorder_args=("${@:8}") + local tool_choice_sequence="$7" + local filename="$8" + local recorder_args=("${@:9}") local temporary_output local next_tools_args=() local continuation_args=() - temporary_output="$(mktemp "$BASE_DIR/.tool-search-cassette.XXXXXX")" + temporary_output="$(mktemp "$STAGING_DIR/.tool-search-cassette.XXXXXX")" if [[ -n "$next_tools" ]]; then next_tools_args=(--tools-after-search "$next_tools") fi if [[ "$projection" == "normalized" || "$projection" == "gateway-public" ]]; then continuation_args=(--no-store --manual-item-replay) fi - if ! python "$SCRIPTS_DIR/record_cassette.py" \ --mode responses \ - --turns 3 \ + --turns 4 \ "${recorder_args[@]}" \ --model "$model" \ "$endpoint_flag" "$endpoint" \ --tools "$tools" \ - --tool-choice auto \ + --tool-choice-sequence "$tool_choice_sequence" \ --tool-outputs "$FUNCTION_OUTPUTS" \ --tool-search-output-tools "$RETURNED_TOOLS" \ "${next_tools_args[@]}" \ @@ -300,13 +78,9 @@ record_scenario() { return 1 fi - if ! validate_recording "$temporary_output" "$projection" "$tools" "$next_tools"; then - rm -f -- "$temporary_output" - return 1 - fi - chmod 664 "$temporary_output" - mv -- "$temporary_output" "$output" - printf 'recorded %s\n' "$output" + mv -- "$temporary_output" "$STAGING_DIR/$filename" + RECORDED_FILES+=("$filename") + printf 'staged %s\n' "$filename" } record_provider() { @@ -317,18 +91,21 @@ record_provider() { local tools="$5" local next_tools="$6" local projection="$7" - local prefix="$8" + local tool_choice_sequence="$8" + local prefix="$9" local slug slug="$(model_slug "$model")" printf 'Recording %s blocking tool-search characterization\n' "$provider" record_scenario \ "$endpoint_flag" "$endpoint" "$model" "$tools" "$next_tools" "$projection" \ - "$BASE_DIR/${prefix}-${slug}-nonstreaming.yaml" --no-stream + "$tool_choice_sequence" \ + "${prefix}-${slug}-nonstreaming.yaml" --no-stream printf 'Recording %s streaming tool-search characterization\n' "$provider" record_scenario \ "$endpoint_flag" "$endpoint" "$model" "$tools" "$next_tools" "$projection" \ - "$BASE_DIR/${prefix}-${slug}-streaming.yaml" --stream + "$tool_choice_sequence" \ + "${prefix}-${slug}-streaming.yaml" --stream } case "$TOOL_SEARCH_RECORD_SET" in @@ -345,7 +122,10 @@ for required_file in \ "$PROMPTS" \ "$OPENAI_TOOLS" \ "$VLLM_INITIAL_TOOLS" \ - "$VLLM_NEXT_TOOLS" + "$VLLM_NEXT_TOOLS" \ + "$OPENAI_TOOL_CHOICES" \ + "$GATEWAY_TOOL_CHOICES" \ + "$VLLM_TOOL_CHOICES" do if [[ ! -f "$required_file" ]]; then printf 'ERROR: required fixture does not exist: %s\n' "$required_file" >&2 @@ -367,10 +147,15 @@ if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-nonstreaming|gateway-streaming|gate exit 1 fi +STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agentic-tool-search-cassettes.XXXXXX")" +trap 'rm -rf -- "$STAGING_DIR"' EXIT +RECORDED_FILES=() +cp -a -- "$BASE_DIR/." "$STAGING_DIR/" + if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(openai-reference|openai|all)$ ]]; then record_provider \ OpenAI --openai https://api.openai.com "$OPENAI_MODEL" \ - "$OPENAI_TOOLS" "" public-stored tool-search-openai-reference + "$OPENAI_TOOLS" "" public-stored "$OPENAI_TOOL_CHOICES" tool-search-openai-reference fi if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-nonstreaming|gateway|all)$ ]]; then @@ -378,7 +163,8 @@ if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-nonstreaming|gateway|all)$ ]]; then printf 'Recording gateway blocking tool-search flow\n' record_scenario \ --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" gateway-public \ - "$BASE_DIR/tool-search-gateway-${slug}-nonstreaming.yaml" --no-stream + "$GATEWAY_TOOL_CHOICES" \ + "tool-search-gateway-${slug}-nonstreaming.yaml" --no-stream fi if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-streaming|gateway|all)$ ]]; then @@ -386,7 +172,8 @@ if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-streaming|gateway|all)$ ]]; then printf 'Recording gateway HTTP/SSE tool-search flow\n' record_scenario \ --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" public-stored \ - "$BASE_DIR/tool-search-gateway-${slug}-streaming.yaml" --stream + "$GATEWAY_TOOL_CHOICES" \ + "tool-search-gateway-${slug}-streaming.yaml" --stream fi if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-websocket|gateway|all)$ ]]; then @@ -394,11 +181,22 @@ if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(gateway-websocket|gateway|all)$ ]]; then printf 'Recording gateway WebSocket tool-search flow\n' record_scenario \ --gateway "$GATEWAY_URL" "$GATEWAY_MODEL" "$OPENAI_TOOLS" "" public-stored \ - "$BASE_DIR/tool-search-gateway-${slug}-websocket.yaml" --stream --transport websocket + "$GATEWAY_TOOL_CHOICES" \ + "tool-search-gateway-${slug}-websocket.yaml" --stream --transport websocket fi if [[ "$TOOL_SEARCH_RECORD_SET" =~ ^(direct-vllm|vllm|all)$ ]]; then record_provider \ direct-vLLM --vllm "$VLLM_URL" "$VLLM_MODEL" \ - "$VLLM_INITIAL_TOOLS" "$VLLM_NEXT_TOOLS" normalized tool-search-direct-vllm + "$VLLM_INITIAL_TOOLS" "$VLLM_NEXT_TOOLS" normalized "$VLLM_TOOL_CHOICES" tool-search-direct-vllm fi + +printf 'Validating tool-search cassette matrix\n' +TOOL_SEARCH_CASSETTE_DIR="$STAGING_DIR" cargo test --manifest-path "$SCRIPTS_DIR/../../../../Cargo.toml" \ + -p agentic-server-core --test tool_search_characterization_test + +for filename in "${RECORDED_FILES[@]}"; do + chmod 664 "$STAGING_DIR/$filename" + mv -- "$STAGING_DIR/$filename" "$BASE_DIR/$filename" + printf 'recorded %s\n' "$BASE_DIR/$filename" +done diff --git a/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py b/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py index 362a8320..2b2b32a5 100644 --- a/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py +++ b/crates/agentic-server-core/tests/cassettes/test_record_tool_search.py @@ -24,43 +24,107 @@ }, "strict": True, "defer_loading": True, - } + }, + { + "type": "namespace", + "name": "travel", + "description": "Travel tools.", + "tools": [ + { + "type": "function", + "name": "get_timezone", + "description": "Get the time zone for a city.", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "strict": True, + "defer_loading": True, + } + ], + }, +] + +FLAT_TIMEZONE_NAME = "agentic_ns__travel__get_timezone" +OPENAI_TOOL_CHOICES = [ + "required", + {"type": "function", "name": "get_weather"}, + "auto", + "none", +] +GATEWAY_TOOL_CHOICES = [ + "required", + {"type": "function", "name": "get_weather"}, + {"type": "function", "namespace": "travel", "name": "get_timezone"}, + "none", +] +NORMALIZED_TOOL_CHOICES = [ + {"type": "function", "name": "tool_search"}, + {"type": "function", "name": "get_weather"}, + {"type": "function", "name": FLAT_TIMEZONE_NAME}, + "none", ] class RecordToolSearchTests(unittest.TestCase): - def test_proxy_start_requires_an_owned_listener_before_request_execution(self) -> None: - class FailedServer: - started = False - should_exit = False - force_exit = False + def test_mixed_catalog_fixture_loads_one_function_and_one_namespace_member(self) -> None: + fixture_dir = Path(__file__).parent / "tool_search" + initial = json.loads((fixture_dir / "openai_tools.json").read_text(encoding="utf-8")) + returned = json.loads((fixture_dir / "returned_tools.json").read_text(encoding="utf-8")) + normalized_initial = json.loads( + (fixture_dir / "vllm_initial_tools.json").read_text(encoding="utf-8") + ) + normalized_loaded = json.loads( + (fixture_dir / "vllm_tools_after_search.json").read_text(encoding="utf-8") + ) + openai_choices = json.loads( + (fixture_dir / "openai_tool_choice_sequence.json").read_text(encoding="utf-8") + ) + gateway_choices = json.loads( + (fixture_dir / "gateway_tool_choice_sequence.json").read_text(encoding="utf-8") + ) + normalized_choices = json.loads( + (fixture_dir / "vllm_tool_choice_sequence.json").read_text(encoding="utf-8") + ) - def run(self) -> None: - return None + ordinary = [tool for tool in initial if tool["type"] == "function"] + namespaces = [tool for tool in initial if tool["type"] == "namespace"] + self.assertEqual( + [tool["name"] for tool in ordinary], + ["get_weather", "get_exchange_rate", "search_hotels"], + ) + self.assertTrue(all(tool["defer_loading"] is True for tool in ordinary)) + self.assertEqual(len(namespaces), 1) + self.assertEqual(namespaces[0]["name"], "travel") + self.assertEqual( + [member["name"] for member in namespaces[0]["tools"]], + ["get_timezone", "get_coordinates", "calculate_distance"], + ) + self.assertTrue( + all(member["defer_loading"] is True for member in namespaces[0]["tools"]) + ) - failed_server = FailedServer() - with tempfile.TemporaryDirectory() as directory: - capture = Path(directory) / "capture.yaml" - with ( - mock.patch.object(record_cassette.uvicorn, "Server", return_value=failed_server), - mock.patch.object(record_cassette, "run_responses") as run_responses, - ): - result = CliRunner().invoke( - record_cassette.main, - [ - "--mode", "responses", - "--turns", "1", - "--gateway", "http://gateway.test", - "--model", "test-model", - "--no-stream", - "--output", str(capture), - ], - ) + self.assertEqual([tool["type"] for tool in returned], ["function", "namespace"]) + self.assertEqual(returned[0]["name"], "get_weather") + self.assertEqual(returned[1]["name"], "travel") + self.assertEqual([member["name"] for member in returned[1]["tools"]], ["get_timezone"]) - self.assertNotEqual(result.exit_code, 0) - self.assertIn("failed to own", str(result.exception)) - self.assertTrue(failed_server.should_exit) - run_responses.assert_not_called() + self.assertEqual([tool["name"] for tool in normalized_initial], ["tool_search"]) + self.assertEqual( + [tool["name"] for tool in normalized_loaded], + ["tool_search", "get_weather", FLAT_TIMEZONE_NAME], + ) + normalized_names = {tool["name"] for tool in normalized_loaded} + self.assertTrue( + normalized_names.isdisjoint( + {"get_exchange_rate", "search_hotels", "get_coordinates", "calculate_distance"} + ) + ) + self.assertEqual(openai_choices, OPENAI_TOOL_CHOICES) + self.assertEqual(gateway_choices, GATEWAY_TOOL_CHOICES) + self.assertEqual(normalized_choices, NORMALIZED_TOOL_CHOICES) def test_gateway_cli_profile_accepts_public_store_false_manual_replay(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -68,10 +132,12 @@ def test_gateway_cli_profile_accepts_public_store_false_manual_replay(self) -> N tools = directory_path / "tools.json" outputs = directory_path / "outputs.json" returned = directory_path / "returned.json" + choices = directory_path / "choices.json" capture = directory_path / "capture.yaml" tools.write_text(json.dumps([{"type": "tool_search", "execution": "client"}]), encoding="utf-8") outputs.write_text(json.dumps({"get_weather": "sunny"}), encoding="utf-8") returned.write_text(json.dumps(RETURNED_TOOLS), encoding="utf-8") + choices.write_text(json.dumps(GATEWAY_TOOL_CHOICES), encoding="utf-8") with ( mock.patch.object(record_cassette, "_start_proxy", return_value=object()), @@ -82,7 +148,7 @@ def test_gateway_cli_profile_accepts_public_store_false_manual_replay(self) -> N record_cassette.main, [ "--mode", "responses", - "--turns", "3", + "--turns", "4", "--gateway", "http://gateway.test", "--model", "test-model", "--no-stream", @@ -91,12 +157,14 @@ def test_gateway_cli_profile_accepts_public_store_false_manual_replay(self) -> N "--tools", str(tools), "--tool-outputs", str(outputs), "--tool-search-output-tools", str(returned), + "--tool-choice-sequence", str(choices), "--output", str(capture), ], ) self.assertEqual(result.exit_code, 0, result.output) self.assertIs(run_responses.call_args.kwargs["manual_item_replay"], True) + self.assertEqual(run_responses.call_args.kwargs["tool_choice_sequence"], GATEWAY_TOOL_CHOICES) self.assertIs(run_responses.call_args.args[4], False, "gateway profile must set store=false") def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> None: @@ -105,10 +173,12 @@ def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> tools = directory_path / "tools.json" outputs = directory_path / "outputs.json" returned = directory_path / "returned.json" + choices = directory_path / "choices.json" capture = directory_path / "capture.yaml" tools.write_text(json.dumps([{"type": "tool_search", "execution": "client"}]), encoding="utf-8") outputs.write_text(json.dumps({"get_weather": "sunny"}), encoding="utf-8") returned.write_text(json.dumps(RETURNED_TOOLS), encoding="utf-8") + choices.write_text(json.dumps(GATEWAY_TOOL_CHOICES), encoding="utf-8") with ( mock.patch.object(record_cassette, "_start_proxy") as start_proxy, @@ -118,7 +188,7 @@ def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> record_cassette.main, [ "--mode", "responses", - "--turns", "3", + "--turns", "4", "--gateway", "http://gateway.test", "--transport", "websocket", "--model", "test-model", @@ -126,6 +196,7 @@ def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> "--tools", str(tools), "--tool-outputs", str(outputs), "--tool-search-output-tools", str(returned), + "--tool-choice-sequence", str(choices), "--output", str(capture), ], ) @@ -134,6 +205,41 @@ def test_gateway_websocket_cli_profile_accepts_stored_tool_search_flow(self) -> start_proxy.assert_not_called() self.assertIs(run_responses.call_args.args[4], True) self.assertEqual(run_responses.call_args.args[7], "websocket") + self.assertEqual(run_responses.call_args.kwargs["tool_choice_sequence"], GATEWAY_TOOL_CHOICES) + + def test_tool_search_cli_requires_one_tool_choice_per_turn(self) -> None: + with tempfile.TemporaryDirectory() as directory: + directory_path = Path(directory) + tools = directory_path / "tools.json" + outputs = directory_path / "outputs.json" + returned = directory_path / "returned.json" + short_choices = directory_path / "short-choices.json" + capture = directory_path / "capture.yaml" + tools.write_text(json.dumps([{"type": "tool_search", "execution": "client"}]), encoding="utf-8") + outputs.write_text(json.dumps({"get_weather": "sunny"}), encoding="utf-8") + returned.write_text(json.dumps(RETURNED_TOOLS), encoding="utf-8") + short_choices.write_text(json.dumps(GATEWAY_TOOL_CHOICES[:-1]), encoding="utf-8") + common_args = [ + "--mode", "responses", + "--turns", "4", + "--gateway", "http://gateway.test", + "--model", "test-model", + "--tools", str(tools), + "--tool-outputs", str(outputs), + "--tool-search-output-tools", str(returned), + "--output", str(capture), + ] + + missing = CliRunner().invoke(record_cassette.main, common_args) + short = CliRunner().invoke( + record_cassette.main, + [*common_args, "--tool-choice-sequence", str(short_choices)], + ) + + self.assertNotEqual(missing.exit_code, 0) + self.assertIn("requires --tool-choice-sequence", missing.output) + self.assertNotEqual(short.exit_code, 0) + self.assertIn("one JSON value per turn", short.output) def test_websocket_handshake_preserves_coalesced_first_frame(self) -> None: class Socket: @@ -151,74 +257,6 @@ def recv(self, _size: int) -> bytes: self.assertTrue(response.endswith("\r\n\r\n")) self.assertEqual(client.receive_text(), "ok") - def test_websocket_rejects_oversized_frames_before_reading_payload(self) -> None: - class Socket: - def __init__(self) -> None: - self.chunks = [b"\x81\x7f", (5).to_bytes(8, "big")] - - def recv(self, _size: int) -> bytes: - return self.chunks.pop(0) if self.chunks else b"" - - client = record_cassette.WebSocketClient("ws://gateway.test", {}) - client.sock = Socket() - - with ( - mock.patch.object(record_cassette, "MAX_WEBSOCKET_FRAME_BYTES", 4), - self.assertRaisesRegex(ValueError, "frame exceeded"), - ): - client.receive_text() - - def test_websocket_rejects_oversized_fragmented_messages(self) -> None: - class Socket: - def __init__(self) -> None: - self.chunks = [b"\x01\x03", b"abc", b"\x80\x02", b"de"] - - def recv(self, _size: int) -> bytes: - return self.chunks.pop(0) if self.chunks else b"" - - socket = Socket() - client = record_cassette.WebSocketClient("ws://gateway.test", {}) - client.sock = socket - - with ( - mock.patch.object(record_cassette, "MAX_WEBSOCKET_MESSAGE_BYTES", 4), - self.assertRaisesRegex(ValueError, "message exceeded"), - ): - client.receive_text() - - self.assertEqual(socket.chunks, [b"de"], "oversized payload must not be read") - - def test_websocket_recording_rejects_an_oversized_capture(self) -> None: - class Socket: - def __enter__(self) -> "Socket": - return self - - def __exit__(self, *_args: object) -> None: - return None - - def send_text(self, _text: str) -> None: - return None - - def receive_text(self) -> str: - return "12345" - - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "capture.yaml" - with ( - mock.patch.object(record_cassette, "WebSocketClient", return_value=Socket()), - mock.patch.object(record_cassette, "MAX_WEBSOCKET_CAPTURE_BYTES", 4), - mock.patch.object(record_cassette, "_append_turn") as append_turn, - self.assertRaisesRegex(ValueError, "byte recording limit"), - ): - record_cassette._send_websocket( - {"model": "test", "input": "hello"}, - "http://gateway.test", - {}, - output, - ) - - append_turn.assert_not_called() - def test_websocket_recording_stops_on_response_failed(self) -> None: failed = { "type": "response.failed", @@ -264,118 +302,42 @@ def receive_text(self) -> str | None: self.assertEqual(json.loads(turn["response"]["websocket"][0]), failed) self.assertTrue(turn["response"]["sse"][0].startswith("event: response.failed\n")) - def test_public_returned_fixture_preserves_deferral_but_vllm_next_tools_clear_it(self) -> None: - fixture_directory = Path(__file__).with_name("tool_search") - returned = json.loads((fixture_directory / "returned_tools.json").read_text(encoding="utf-8")) - vllm_next = json.loads( - (fixture_directory / "vllm_tools_after_search.json").read_text(encoding="utf-8") - ) - self.assertIs(returned[0]["defer_loading"], True) - loaded = next(tool for tool in vllm_next if tool.get("name") == "get_weather") - self.assertNotIn("defer_loading", loaded) - - def test_public_search_then_function_outputs_share_one_continuation_builder(self) -> None: - search_calls = record_cassette._extract_tool_calls( - { - "output": [ - { - "id": "tsc_public", - "type": "tool_search_call", - "call_id": "call_search_public", - "execution": "client", - "status": "completed", - "arguments": {"query": "weather tool"}, - } - ] - } - ) - - search_continuation = record_cassette._build_tool_continuation( - search_calls, - {"get_weather": '{"temperature_c":21}'}, - RETURNED_TOOLS, + def test_tool_search_outputs_use_public_and_synthetic_wire_shapes(self) -> None: + public = record_cassette._build_tool_output_input( + [{"type": "tool_search_call", "call_id": "call_public"}], + {}, None, + RETURNED_TOOLS, ) - - self.assertTrue(search_continuation.loaded_search_tools) - self.assertEqual( - search_continuation.input_items, + synthetic = record_cassette._build_tool_output_input( [ { - "type": "tool_search_output", - "call_id": "call_search_public", - "execution": "client", - "status": "completed", - "tools": RETURNED_TOOLS, + "type": "function_call", + "name": "tool_search", + "call_id": "call_synthetic", } ], - ) - - function_calls = record_cassette._extract_tool_calls( - { - "output": [ - { - "id": "fc_weather", - "type": "function_call", - "call_id": "call_weather", - "name": "get_weather", - "arguments": '{"city":"Paris"}', - } - ] - } - ) - function_continuation = record_cassette._build_tool_continuation( - function_calls, - {"get_weather": '{"temperature_c":21}'}, - RETURNED_TOOLS, + {}, None, + RETURNED_TOOLS, ) - self.assertFalse(function_continuation.loaded_search_tools) self.assertEqual( - function_continuation.input_items, + public, [ { - "type": "function_call_output", - "call_id": "call_weather", - "output": '{"temperature_c":21}', + "type": "tool_search_output", + "call_id": "call_public", + "execution": "client", + "status": "completed", + "tools": RETURNED_TOOLS, } ], ) - - def test_normalized_search_uses_canonical_function_output_projection(self) -> None: - calls = record_cassette._extract_tool_calls( - { - "output": [ - { - "id": "fc_search", - "type": "function_call", - "call_id": "call_search_normalized", - "name": "tool_search", - "arguments": '{"query":"weather tool"}', - } - ] - } - ) - - continuation = record_cassette._build_tool_continuation( - calls, - {"get_weather": '{"temperature_c":21}'}, - RETURNED_TOOLS, - None, - ) - - self.assertTrue(continuation.loaded_search_tools) - self.assertEqual(continuation.input_items[0]["type"], "function_call_output") - self.assertEqual(continuation.input_items[0]["call_id"], "call_search_normalized") - expected = json.dumps( - {"tools": RETURNED_TOOLS}, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, + self.assertEqual(synthetic[0]["type"], "function_call_output") + self.assertEqual( + json.loads(synthetic[0]["output"]), {"tools": RETURNED_TOOLS} ) - self.assertEqual(continuation.input_items[0]["output"], expected) - self.assertEqual(json.loads(continuation.input_items[0]["output"]), {"tools": RETURNED_TOOLS}) def test_tool_continuations_reject_empty_ids_and_missing_search_tools(self) -> None: for call in ( @@ -384,10 +346,12 @@ def test_tool_continuations_reject_empty_ids_and_missing_search_tools(self) -> N {"type": "function_call", "name": "get_weather", "call_id": " "}, ): with self.subTest(call=call), self.assertRaises(ValueError): - record_cassette._extract_tool_calls({"output": [call]}) + record_cassette._build_tool_output_input( + [call], {}, None, RETURNED_TOOLS + ) with self.assertRaises(ValueError): - record_cassette._build_tool_continuation( + record_cassette._build_tool_output_input( [ { "type": "tool_search_call", @@ -399,7 +363,7 @@ def test_tool_continuations_reject_empty_ids_and_missing_search_tools(self) -> N None, ) with self.assertRaisesRegex(ValueError, "explicit output fixture"): - record_cassette._build_tool_continuation( + record_cassette._build_tool_output_input( [ { "type": "function_call", @@ -408,12 +372,12 @@ def test_tool_continuations_reject_empty_ids_and_missing_search_tools(self) -> N } ], {}, - RETURNED_TOOLS, None, + RETURNED_TOOLS, ) - def test_existing_outputs_and_central_secret_validation(self) -> None: - continuation = record_cassette._build_tool_continuation( + def test_existing_function_and_custom_outputs_remain_supported(self) -> None: + continuation = record_cassette._build_tool_output_input( [ { "type": "function_call", @@ -427,151 +391,54 @@ def test_existing_outputs_and_central_secret_validation(self) -> None: }, ], {"lookup": "function result", "raw_echo": "custom result"}, - None, "continue", + None, ) self.assertEqual( - [item["type"] for item in continuation.input_items], + [item["type"] for item in continuation], ["function_call_output", "custom_tool_call_output", "message"], ) - safe_turn = { - "request": { - "headers": {"authorization": "Bearer live-key"}, - "query_params": {}, - "body": { - "input": "hello", - "parameters": { - "type": "object", - "properties": {"password": {"type": "string"}}, - }, - }, - }, - "response": {"headers": {}, "body": {"output": []}}, - } - prepared = record_cassette._prepare_turn_for_write(safe_turn, environment={}) - self.assertEqual(prepared["request"]["headers"]["authorization"], "Bearer ***") - self.assertEqual(safe_turn["request"]["headers"]["authorization"], "Bearer live-key") - - unsafe_turns = ( - { - "request": { - "headers": {}, - "query_params": {}, - "body": {"tools": [{"headers": {"x-api-key": "nested-secret"}}]}, - }, - "response": {}, - }, - { - "request": { - "headers": {}, - "query_params": {"api_key": "query-secret"}, - "body": {}, - }, - "response": {}, - }, - { - "request": {"headers": {}, "query_params": {}, "body": {}}, - "response": {"body": {"error": {"message": "failed with sk-live-secret"}}}, - }, - { - "request": { - "headers": {}, - "query_params": {}, - "body": { - "tools": [ - { - "type": "mcp", - "server_url": "https://mcp.example.test/run", - "headers": {"X-Tenant": "tenant-secret"}, - } - ] - }, - }, - "response": {}, - }, - { - "request": { - "headers": {}, - "query_params": {}, - "body": { - "tools": [ - { - "type": "mcp", - "server_url": "https://user@mcp.example.test/run?tenant=private", - } - ] - }, - }, - "response": {}, - }, - { - "request": { - "headers": {}, - "query_params": {}, - "body": { - "image_url": "https://files.example.test/object?X-Amz-Credential=credential&X-Amz-Signature=signed" - }, - }, - "response": {}, - }, - { - "request": {"headers": {}, "query_params": {}, "body": {}}, - "response": { - "body": { - "output": '{"tools":[{"type":"mcp","headers":{"X-Tenant":"nested-secret"}}]}' - } - }, - }, + def test_namespaced_and_flattened_calls_use_explicit_output_fixtures(self) -> None: + public = record_cassette._build_tool_output_input( + [ + { + "type": "function_call", + "namespace": "travel", + "name": "get_timezone", + "call_id": "call_public_timezone", + } + ], + {"get_timezone": "public time zone"}, + None, + RETURNED_TOOLS, ) - environments = ( - {}, - {}, - {"OPENAI_API_KEY": "sk-live-secret"}, - {}, - {}, - {}, - {}, + normalized = record_cassette._build_tool_output_input( + [ + { + "type": "function_call", + "name": FLAT_TIMEZONE_NAME, + "call_id": "call_flat_timezone", + } + ], + {FLAT_TIMEZONE_NAME: "normalized time zone"}, + None, + RETURNED_TOOLS, ) - for unsafe_turn, environment in zip(unsafe_turns, environments, strict=True): - with self.subTest(turn=unsafe_turn), self.assertRaises( - record_cassette.SecretRecordingError - ): - record_cassette._prepare_turn_for_write(unsafe_turn, environment=environment) - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "capture.yaml" - with self.assertRaises(record_cassette.SecretRecordingError): - record_cassette._append_turn( - output, - unsafe_turns[0], - environment={}, - ) - self.assertFalse(output.exists()) + self.assertEqual(public[0]["output"], "public time zone") + self.assertEqual(normalized[0]["output"], "normalized time zone") - def test_append_turn_preserves_existing_mode_and_cleans_temporary_file(self) -> None: - turn = { - "request": {"headers": {}, "query_params": {}, "body": {"input": "safe"}}, - "response": {"headers": {}, "body": {"output": []}}, - } - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "capture.yaml" - output.write_text("turns: []\n", encoding="utf-8") - output.chmod(0o664) - - record_cassette._append_turn(output, turn, environment={}) - - self.assertEqual(output.stat().st_mode & 0o777, 0o664) - self.assertEqual(list(output.parent.glob(f".{output.name}.*")), []) - - new_output = Path(directory) / "new-capture.yaml" - record_cassette._append_turn(new_output, turn, environment={}) - self.assertEqual(new_output.stat().st_mode & 0o077, 0) - self.assertEqual(list(new_output.parent.glob(f".{new_output.name}.*")), []) - - def test_linear_responses_flow_switches_to_next_tools_and_rejects_branches(self) -> None: + def test_synthetic_manual_replay_switches_to_loaded_tools(self) -> None: initial_tools = [{"type": "function", "name": "tool_search"}] - next_tools = initial_tools + RETURNED_TOOLS + next_tools = initial_tools + [ + RETURNED_TOOLS[0], + { + **RETURNED_TOOLS[1]["tools"][0], + "name": FLAT_TIMEZONE_NAME, + "defer_loading": False, + }, + ] responses = [ { "id": "resp_search", @@ -586,7 +453,7 @@ def test_linear_responses_flow_switches_to_next_tools_and_rejects_branches(self) ], }, { - "id": "resp_function", + "id": "resp_weather", "output": [ { "type": "function_call", @@ -597,6 +464,18 @@ def test_linear_responses_flow_switches_to_next_tools_and_rejects_branches(self) } ], }, + { + "id": "resp_timezone", + "output": [ + { + "type": "function_call", + "name": FLAT_TIMEZONE_NAME, + "call_id": "call_timezone", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + ], + }, {"id": "resp_final", "output": [{"type": "message"}]}, ] sent_bodies: list[dict] = [] @@ -609,35 +488,44 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> mock.patch.object( record_cassette, "_prompt", - side_effect=["find a weather tool", "call it", "finish"], + side_effect=["find tools", "call weather", "call timezone", "finish"], ), mock.patch.object(record_cassette, "_send", side_effect=fake_send), ): record_cassette.run_responses( client=object(), - turns=3, + turns=4, model="test-model", stream=False, store=False, branches=[], proxy_url="http://unused", tools=initial_tools, - tool_outputs={"get_weather": '{"temperature_c":21}'}, + tool_choice_sequence=NORMALIZED_TOOL_CHOICES, + tool_outputs={ + "get_weather": '{"temperature_c":21}', + FLAT_TIMEZONE_NAME: '{"iana_timezone":"Europe/Paris"}', + }, tool_search_output_tools=RETURNED_TOOLS, tools_after_search=next_tools, manual_item_replay=True, ) - self.assertEqual([body["tools"] for body in sent_bodies], [initial_tools, next_tools, next_tools]) + self.assertEqual( + [body["tools"] for body in sent_bodies], + [initial_tools, next_tools, next_tools, next_tools], + ) self.assertTrue(all(body["store"] is False for body in sent_bodies)) self.assertTrue(all("previous_response_id" not in body for body in sent_bodies)) + self.assertEqual([body["tool_choice"] for body in sent_bodies], NORMALIZED_TOOL_CHOICES) turn_one_input = sent_bodies[0]["input"] turn_two_input = sent_bodies[1]["input"] turn_three_input = sent_bodies[2]["input"] + turn_four_input = sent_bodies[3]["input"] self.assertEqual( turn_one_input, - [{"type": "message", "role": "user", "content": "find a weather tool"}], + [{"type": "message", "role": "user", "content": "find tools"}], ) self.assertEqual(turn_two_input[: len(turn_one_input)], turn_one_input) self.assertEqual(turn_two_input[1]["type"], "function_call") @@ -645,34 +533,34 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> self.assertEqual(turn_two_input[2]["type"], "function_call_output") self.assertEqual(turn_two_input[2]["call_id"], "call_search") self.assertEqual(turn_three_input[: len(turn_two_input)], turn_two_input) - loaded_call = next( + weather_call = next( item for item in turn_three_input[len(turn_two_input) :] if item.get("type") == "function_call" ) - loaded_output = next( + weather_output = next( item for item in turn_three_input[len(turn_two_input) :] if item.get("type") == "function_call_output" ) - self.assertEqual(loaded_call["call_id"], "call_weather") - self.assertEqual(loaded_output["call_id"], "call_weather") + self.assertEqual(weather_call["call_id"], "call_weather") + self.assertEqual(weather_output["call_id"], "call_weather") + self.assertEqual(turn_four_input[: len(turn_three_input)], turn_three_input) + timezone_call = next( + item + for item in turn_four_input[len(turn_three_input) :] + if item.get("type") == "function_call" + ) + timezone_output = next( + item + for item in turn_four_input[len(turn_three_input) :] + if item.get("type") == "function_call_output" + ) + self.assertEqual(timezone_call["name"], FLAT_TIMEZONE_NAME) + self.assertEqual(timezone_call["call_id"], "call_timezone") + self.assertEqual(timezone_output["call_id"], "call_timezone") self.assertTrue(all(body["parallel_tool_calls"] is False for body in sent_bodies)) - with self.assertRaisesRegex(record_cassette.click.UsageError, "branching"): - record_cassette.run_responses( - client=object(), - turns=3, - model="test-model", - stream=False, - store=True, - branches=[(1, 2)], - proxy_url="http://unused", - tools=initial_tools, - tool_outputs={"get_weather": "result"}, - tool_search_output_tools=RETURNED_TOOLS, - ) - def test_public_linear_responses_flow_keeps_public_top_level_tools(self) -> None: public_tools = [ {"type": "tool_search", "execution": "client"}, @@ -681,6 +569,7 @@ def test_public_linear_responses_flow_keeps_public_top_level_tools(self) -> None "name": "get_weather", "defer_loading": True, }, + RETURNED_TOOLS[1], ] responses = [ { @@ -696,7 +585,7 @@ def test_public_linear_responses_flow_keeps_public_top_level_tools(self) -> None ], }, { - "id": "resp_function", + "id": "resp_weather", "output": [ { "type": "function_call", @@ -707,6 +596,19 @@ def test_public_linear_responses_flow_keeps_public_top_level_tools(self) -> None } ], }, + { + "id": "resp_timezone", + "output": [ + { + "type": "function_call", + "namespace": "travel", + "name": "get_timezone", + "call_id": "call_timezone", + "status": "completed", + "arguments": '{"city":"Paris"}', + } + ], + }, {"id": "resp_final", "output": [{"type": "message"}]}, ] sent_bodies: list[dict] = [] @@ -719,26 +621,32 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> mock.patch.object( record_cassette, "_prompt", - side_effect=["find a weather tool", "call it", "finish"], + side_effect=["find tools", "call weather", "call timezone", "finish"], ), mock.patch.object(record_cassette, "_send", side_effect=fake_send), ): record_cassette.run_responses( client=object(), - turns=3, + turns=4, model="test-model", stream=False, store=True, branches=[], proxy_url="http://unused", tools=public_tools, - tool_outputs={"get_weather": '{"temperature_c":21}'}, + tool_choice_sequence=GATEWAY_TOOL_CHOICES, + tool_outputs={ + "get_weather": '{"temperature_c":21}', + "get_timezone": '{"iana_timezone":"Europe/Paris"}', + }, tool_search_output_tools=RETURNED_TOOLS, ) self.assertEqual(sent_bodies[0]["tools"], public_tools) + self.assertEqual([body["tool_choice"] for body in sent_bodies], GATEWAY_TOOL_CHOICES) self.assertNotIn("tools", sent_bodies[1]) self.assertNotIn("tools", sent_bodies[2]) + self.assertNotIn("tools", sent_bodies[3]) public_output = sent_bodies[1]["input"][0] self.assertEqual(public_output["type"], "tool_search_output") self.assertEqual(public_output["call_id"], "call_search") @@ -746,12 +654,15 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> self.assertEqual(public_output["status"], "completed") self.assertEqual(public_output["tools"], RETURNED_TOOLS) self.assertEqual(sent_bodies[2]["input"][0]["type"], "function_call_output") + self.assertEqual(sent_bodies[3]["input"][0]["type"], "function_call_output") + self.assertEqual(sent_bodies[3]["input"][0]["call_id"], "call_timezone") self.assertTrue(all(body["parallel_tool_calls"] is False for body in sent_bodies)) def test_gateway_public_manual_replay_is_store_false_and_omits_tools_after_search(self) -> None: public_tools = [ {"type": "tool_search", "execution": "client"}, {"type": "function", "name": "get_weather", "defer_loading": True}, + RETURNED_TOOLS[1], ] responses = [ { @@ -766,7 +677,7 @@ def test_gateway_public_manual_replay_is_store_false_and_omits_tools_after_searc }], }, { - "id": "resp_function", + "id": "resp_weather", "output": [{ "type": "function_call", "id": "fc_weather", @@ -776,6 +687,18 @@ def test_gateway_public_manual_replay_is_store_false_and_omits_tools_after_searc "arguments": '{"city":"Paris"}', }], }, + { + "id": "resp_timezone", + "output": [{ + "type": "function_call", + "id": "fc_timezone", + "namespace": "travel", + "name": "get_timezone", + "call_id": "call_timezone", + "status": "completed", + "arguments": '{"city":"Paris"}', + }], + }, {"id": "resp_final", "output": [{"type": "message"}]}, ] sent_bodies: list[dict] = [] @@ -785,19 +708,24 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> return responses[len(sent_bodies) - 1] with ( - mock.patch.object(record_cassette, "_prompt", side_effect=["find", "call", "finish"]), + mock.patch.object( + record_cassette, + "_prompt", + side_effect=["find", "call weather", "call timezone", "finish"], + ), mock.patch.object(record_cassette, "_send", side_effect=fake_send), ): record_cassette.run_responses( client=object(), - turns=3, + turns=4, model="test-model", stream=False, store=False, branches=[], proxy_url="http://unused", tools=public_tools, - tool_outputs={"get_weather": "sunny"}, + tool_choice_sequence=GATEWAY_TOOL_CHOICES, + tool_outputs={"get_weather": "sunny", "get_timezone": "Europe/Paris"}, tool_search_output_tools=RETURNED_TOOLS, manual_item_replay=True, ) @@ -805,119 +733,19 @@ def fake_send(_client: object, body: dict, *_args: object, **_kwargs: object) -> self.assertTrue(all(body["store"] is False for body in sent_bodies)) self.assertTrue(all("previous_response_id" not in body for body in sent_bodies)) self.assertEqual(sent_bodies[0]["tools"], public_tools) + self.assertEqual([body["tool_choice"] for body in sent_bodies], GATEWAY_TOOL_CHOICES) self.assertNotIn("tools", sent_bodies[1]) self.assertNotIn("tools", sent_bodies[2]) + self.assertNotIn("tools", sent_bodies[3]) self.assertEqual(sent_bodies[1]["input"][1]["type"], "tool_search_call") self.assertEqual(sent_bodies[1]["input"][2]["type"], "tool_search_output") self.assertEqual(sent_bodies[2]["input"][3]["type"], "message") self.assertEqual(sent_bodies[2]["input"][4]["type"], "function_call") self.assertEqual(sent_bodies[2]["input"][5]["type"], "function_call_output") - - def test_turn_validation_requires_search_and_object_loaded_arguments(self) -> None: - valid_public = { - "type": "tool_search_call", - "call_id": "call_search", - "execution": "client", - "status": "completed", - "arguments": {"query": "weather tool"}, - } - valid_normalized = { - "type": "function_call", - "name": "tool_search", - "call_id": "call_search", - "status": "completed", - "arguments": '{"query":"weather tool"}', - } - valid_loaded = { - "type": "function_call", - "name": "get_weather", - "call_id": "call_weather", - "status": "completed", - "arguments": '{"city":"Paris"}', - } - record_cassette._validate_tool_search_turn_calls(2, [valid_public], RETURNED_TOOLS) - record_cassette._validate_tool_search_turn_calls(2, [valid_normalized], RETURNED_TOOLS) - record_cassette._validate_tool_search_turn_calls(3, [valid_loaded], RETURNED_TOOLS) - record_cassette._validate_tool_search_turn_calls( - 3, - [{**valid_loaded, "arguments": '{"city":"London"}'}], - RETURNED_TOOLS, - ) - - invalid_calls = ( - (2, {key: value for key, value in valid_public.items() if key != "execution"}), - (2, {**valid_public, "execution": "server"}), - (2, {**valid_public, "status": "in_progress"}), - (2, {**valid_public, "arguments": '{"query":"weather tool"}'}), - (2, {key: value for key, value in valid_normalized.items() if key != "status"}), - (2, {**valid_normalized, "status": "in_progress"}), - (2, {**valid_normalized, "arguments": "{}"}), - (2, {**valid_normalized, "arguments": "not-json"}), - (3, {key: value for key, value in valid_loaded.items() if key != "status"}), - (3, {**valid_loaded, "status": "in_progress"}), - (3, {**valid_loaded, "arguments": "[]"}), - ) - for turn, call in invalid_calls: - with self.subTest(turn=turn, call=call), self.assertRaises(ValueError): - record_cassette._validate_tool_search_turn_calls(turn, [call], RETURNED_TOOLS) - - def test_linear_flow_rejects_a_final_tool_call(self) -> None: - responses = [ - { - "id": "resp_search", - "output": [ - { - "type": "tool_search_call", - "call_id": "call_search", - "execution": "client", - "status": "completed", - "arguments": {"query": "weather tool"}, - } - ], - }, - { - "id": "resp_function", - "output": [ - { - "type": "function_call", - "name": "get_weather", - "call_id": "call_weather", - "status": "completed", - "arguments": '{"city":"Paris"}', - } - ], - }, - { - "id": "resp_bad_final", - "output": [ - { - "type": "function_call", - "name": "get_weather", - "call_id": "call_again", - "arguments": '{"city":"Paris"}', - } - ], - }, - ] - - with ( - mock.patch.object(record_cassette, "_prompt", side_effect=["search", "call", "finish"]), - mock.patch.object(record_cassette, "_send", side_effect=responses), - self.assertRaisesRegex(ValueError, "final response"), - ): - record_cassette.run_responses( - client=object(), - turns=3, - model="test-model", - stream=False, - store=True, - branches=[], - proxy_url="http://unused", - tools=[{"type": "tool_search"}], - tool_outputs={"get_weather": "weather"}, - tool_search_output_tools=RETURNED_TOOLS, - ) - + self.assertEqual(sent_bodies[3]["input"][7]["namespace"], "travel") + self.assertEqual(sent_bodies[3]["input"][7]["name"], "get_timezone") + self.assertEqual(sent_bodies[3]["input"][8]["type"], "function_call_output") + self.assertEqual(sent_bodies[3]["input"][9]["type"], "message") if __name__ == "__main__": unittest.main() diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json b/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json index 0301a7b5..fd66a5fa 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json +++ b/crates/agentic-server-core/tests/cassettes/tool_search/function_outputs.json @@ -1,3 +1,5 @@ { - "get_weather": "{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}" + "get_weather": "{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}", + "get_timezone": "{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}", + "agentic_ns__travel__get_timezone": "{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}" } diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/gateway_tool_choice_sequence.json b/crates/agentic-server-core/tests/cassettes/tool_search/gateway_tool_choice_sequence.json new file mode 100644 index 00000000..60c1e1db --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/gateway_tool_choice_sequence.json @@ -0,0 +1,13 @@ +[ + "required", + { + "type": "function", + "name": "get_weather" + }, + { + "type": "function", + "namespace": "travel", + "name": "get_timezone" + }, + "none" +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/openai_tool_choice_sequence.json b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tool_choice_sequence.json new file mode 100644 index 00000000..e54df17d --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tool_choice_sequence.json @@ -0,0 +1,9 @@ +[ + "required", + { + "type": "function", + "name": "get_weather" + }, + "auto", + "none" +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json index 150e5482..aa15cee3 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json +++ b/crates/agentic-server-core/tests/cassettes/tool_search/openai_tools.json @@ -2,13 +2,13 @@ { "type": "tool_search", "execution": "client", - "description": "Search the client tool catalog for a tool that can satisfy the request.", + "description": "Search the client tool catalog for tools that can satisfy the request.", "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "A concise description of the needed capability." + "description": "A concise description of the needed capabilities." } }, "required": ["query"], @@ -18,7 +18,7 @@ { "type": "function", "name": "get_weather", - "description": "Get the current weather for a city.", + "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { @@ -31,5 +31,103 @@ }, "strict": true, "defer_loading": true + }, + { + "type": "function", + "name": "get_exchange_rate", + "description": "Get the exchange rate between two currencies", + "parameters": { + "type": "object", + "properties": { + "base": { + "type": "string" + }, + "quote": { + "type": "string" + } + }, + "required": ["base", "quote"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "search_hotels", + "description": "Search for hotels in a city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "namespace", + "name": "travel", + "description": "Travel location tools", + "tools": [ + { + "type": "function", + "name": "get_timezone", + "description": "Get the IANA time zone for a city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "get_coordinates", + "description": "Get latitude and longitude for a city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "calculate_distance", + "description": "Calculate the distance between two cities", + "parameters": { + "type": "object", + "properties": { + "origin": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": ["origin", "destination"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] } ] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt b/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt index db3697ed..91c71a36 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt +++ b/crates/agentic-server-core/tests/cassettes/tool_search/prompts.txt @@ -1,3 +1,4 @@ -First call tool_search exactly once to find a weather tool. Do not call get_weather yet. -Now call get_weather exactly once with {"city":"Paris"}. Do not call tool_search again. -Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK. +First call tool_search exactly once to find both a current-weather function and a travel time-zone function. Do not call any other tool yet. +Now call get_weather exactly once with {"city":"Paris"}. Do not call any other tool. +Now call the loaded travel namespace member get_timezone exactly once with {"city":"Paris"}. Do not call any other tool. +Use both function outputs and call no more tools. Reply with exactly PARIS_MIXED_TOOLS_OK. diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json index cfe12c80..ca847f7a 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json +++ b/crates/agentic-server-core/tests/cassettes/tool_search/returned_tools.json @@ -2,7 +2,7 @@ { "type": "function", "name": "get_weather", - "description": "Get the current weather for a city.", + "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { @@ -15,5 +15,29 @@ }, "strict": true, "defer_loading": true + }, + { + "type": "namespace", + "name": "travel", + "description": "Travel location tools", + "tools": [ + { + "type": "function", + "name": "get_timezone", + "description": "Get the IANA time zone for a city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] } ] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml index f0061072..59b51c17 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml @@ -3,8 +3,8 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message max_output_tokens: 4096 @@ -12,16 +12,20 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: + name: tool_search + type: function tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather + for a city; get_exchange_rate — Get the exchange rate between two currencies; + search_hotels — Search for hotels in a city; travel — Travel location tools.' name: tool_search parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query @@ -38,9 +42,9 @@ turns: response: body: background: false - created_at: 1787143221 + created_at: 1787714225 frequency_penalty: 0.0 - id: resp_89e5972151968df7 + id: resp_80050f2a83c18eae incomplete_details: null input_messages: null instructions: null @@ -52,32 +56,37 @@ turns: object: response output: - content: - - text: "The user wants me to call `tool_search` exactly once to find a weather\ - \ tool.\nI should use the `tool_search` function with a query like \"\ - weather\" or \"current weather\".\nThe description says: \"Get the current\ - \ weather for a city.\"\nI will call `tool_search` with query \"weather\ - \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ - Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ - query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ - \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ - \ let's verify the exact parameters. `tool_search` requires `query`.\n\ - I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ - \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ - weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ - The prompt says: \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ - \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ - Output matches.\n" + - text: "The user wants me to call `tool_search` exactly once to find:\n1.\ + \ A current-weather function\n2. A travel time-zone function\n\nI need\ + \ to construct a query that asks for both of these capabilities in a single\ + \ search.\nLooking at the available tools in the description:\n- get_weather\ + \ — Get the current weather for a city\n- get_exchange_rate — Get the\ + \ exchange rate between two currencies\n- search_hotels — Search for hotels\ + \ in a city\n- travel — Travel location tools.\n\nI will formulate the\ + \ query to request a tool for current weather and a tool for travel time\ + \ zone.\nQuery: \"a function to get current weather and a function to\ + \ get travel time zones\"\nThen I will call `tool_search` with this query.\n\ + Let's double check the constraints: \"First call tool_search exactly once...\ + \ Do not call any other tool yet.\"\nProceed. \nI will construct the query\ + \ carefully.\n`tool_search(query=\"a function to get current weather and\ + \ a function for travel time zones\")`\nDone. \nWait, let's verify the\ + \ exact description. \"travel — Travel location tools.\" Time zone might\ + \ be part of travel tools.\nI'll just ask for \"current weather and travel\ + \ time zone capabilities\".\nReady. \nCalling tool_search. \nNote: The\ + \ prompt says \"First call tool_search exactly once...\". So I will output\ + \ the tool call now. \nLet's make sure the parameters match: `{\"query\"\ + : \"string\"}`.\nQuery: \"a current-weather function and a travel time-zone\ + \ function\"\nProceeds. \nOutput matches format.✅\n" type: reasoning_text encrypted_content: null - id: rs_86d01db313ae56e6 + id: rs_9e94d568ce77b3e8 status: null summary: [] type: reasoning - - arguments: '{"query": "weather tool"}' - call_id: chatcmpl-tool-aca6cd11d99339b4 + - arguments: '{"query": "a current-weather function and a travel time-zone function"}' + call_id: chatcmpl-tool-bfd01690188cd378 caller: null - id: fc_9ee5ffe95a32e0b8 + id: fc_a54b71918970eabe name: tool_search namespace: null status: completed @@ -92,19 +101,23 @@ turns: status: completed temperature: 1.0 text: null - tool_choice: auto + tool_choice: + name: tool_search + type: function tools: - allowed_callers: null defer_loading: null - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather + for a city; get_exchange_rate — Get the exchange rate between two currencies; + search_hotels — Search for hotels in a city; travel — Travel location tools.' name: tool_search output_schema: null parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query @@ -115,18 +128,18 @@ turns: top_p: 0.95 truncation: disabled usage: - input_tokens: 348 + input_tokens: 394 input_tokens_details: cached_tokens: 0 cached_tokens_per_turn: [] input_tokens_per_turn: [] - output_tokens: 311 + output_tokens: 376 output_tokens_details: output_tokens_per_turn: [] reasoning_tokens: 0 tool_output_tokens: 0 tool_output_tokens_per_turn: [] - total_tokens: 659 + total_tokens: 770 user: null headers: content-type: application/json @@ -135,47 +148,54 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message - content: - - text: "The user wants me to call `tool_search` exactly once to find a weather\ - \ tool.\nI should use the `tool_search` function with a query like \"\ - weather\" or \"current weather\".\nThe description says: \"Get the current\ - \ weather for a city.\"\nI will call `tool_search` with query \"weather\ - \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ - Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ - query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ - \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ - \ let's verify the exact parameters. `tool_search` requires `query`.\n\ - I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ - \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ - weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ - The prompt says: \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ - \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ - Output matches.\n" + - text: "The user wants me to call `tool_search` exactly once to find:\n1.\ + \ A current-weather function\n2. A travel time-zone function\n\nI need\ + \ to construct a query that asks for both of these capabilities in a single\ + \ search.\nLooking at the available tools in the description:\n- get_weather\ + \ — Get the current weather for a city\n- get_exchange_rate — Get the\ + \ exchange rate between two currencies\n- search_hotels — Search for hotels\ + \ in a city\n- travel — Travel location tools.\n\nI will formulate the\ + \ query to request a tool for current weather and a tool for travel time\ + \ zone.\nQuery: \"a function to get current weather and a function to\ + \ get travel time zones\"\nThen I will call `tool_search` with this query.\n\ + Let's double check the constraints: \"First call tool_search exactly once...\ + \ Do not call any other tool yet.\"\nProceed. \nI will construct the query\ + \ carefully.\n`tool_search(query=\"a function to get current weather and\ + \ a function for travel time zones\")`\nDone. \nWait, let's verify the\ + \ exact description. \"travel — Travel location tools.\" Time zone might\ + \ be part of travel tools.\nI'll just ask for \"current weather and travel\ + \ time zone capabilities\".\nReady. \nCalling tool_search. \nNote: The\ + \ prompt says \"First call tool_search exactly once...\". So I will output\ + \ the tool call now. \nLet's make sure the parameters match: `{\"query\"\ + : \"string\"}`.\nQuery: \"a current-weather function and a travel time-zone\ + \ function\"\nProceeds. \nOutput matches format.✅\n" type: reasoning_text encrypted_content: null - id: rs_86d01db313ae56e6 + id: rs_9e94d568ce77b3e8 status: null summary: [] type: reasoning - - arguments: '{"query": "weather tool"}' - call_id: chatcmpl-tool-aca6cd11d99339b4 + - arguments: '{"query": "a current-weather function and a travel time-zone function"}' + call_id: chatcmpl-tool-bfd01690188cd378 caller: null - id: fc_9ee5ffe95a32e0b8 + id: fc_a54b71918970eabe name: tool_search namespace: null status: completed type: function_call - - call_id: chatcmpl-tool-aca6cd11d99339b4 + - call_id: chatcmpl-tool-bfd01690188cd378 output: '{"tools":[{"defer_loading":true,"description":"Get the current weather - for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' type: function_call_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message max_output_tokens: 4096 @@ -183,23 +203,27 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: + name: get_weather + type: function tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' name: tool_search parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object strict: true type: function - - description: Get the current weather for a city. + - description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -211,6 +235,18 @@ turns: type: object strict: true type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function headers: accept: '*/*' content-type: application/json @@ -221,9 +257,9 @@ turns: response: body: background: false - created_at: 1787143223 + created_at: 1787714227 frequency_penalty: 0.0 - id: resp_afbd31d42786a8a2 + id: resp_855b120985e573ea incomplete_details: null input_messages: null instructions: null @@ -235,37 +271,295 @@ turns: object: response output: - content: - - text: 'The user wants me to call `get_weather` with the city "Paris". - - The instruction specifies "exactly once" and "Do not call tool_search - again". + - text: 'The user wants me to call the `get_weather` function with the argument + `{"city": "Paris"}`. - I have found the tool `get_weather` in the previous step. + I should not call any other tool. - I need to construct the function call with the parameter `{"city": "Paris"}`. + I have found the `get_weather` function in the previous step. - The tool signature is `get_weather(city: string)`. + I will proceed with calling `get_weather(city="Paris")`. - The parameter matches. + ' + type: reasoning_text + encrypted_content: null + id: rs_8b055887860d4070 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-b8e069e468543530 + caller: null + id: fc_92d8c37016c26805 + name: get_weather + namespace: null + status: completed + type: function_call + output_messages: null + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: null + prompt: null + reasoning: null + service_tier: auto + status: completed + temperature: 1.0 + text: null + tool_choice: + name: get_weather + type: function + tools: + - allowed_callers: null + defer_loading: null + description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' + name: tool_search + output_schema: null + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capabilities. + type: string + required: + - query + type: object + strict: true + type: function + - allowed_callers: null + defer_loading: null + description: Get the current weather for a city + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - allowed_callers: null + defer_loading: null + description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + top_logprobs: null + top_p: 0.95 + truncation: disabled + usage: + input_tokens: 778 + input_tokens_details: + cached_tokens: 0 + cached_tokens_per_turn: [] + input_tokens_per_turn: [] + output_tokens: 94 + output_tokens_details: + output_tokens_per_turn: [] + reasoning_tokens: 0 + tool_output_tokens: 0 + tool_output_tokens_per_turn: [] + total_tokens: 872 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + input: + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. + role: user + type: message + - content: + - text: "The user wants me to call `tool_search` exactly once to find:\n1.\ + \ A current-weather function\n2. A travel time-zone function\n\nI need\ + \ to construct a query that asks for both of these capabilities in a single\ + \ search.\nLooking at the available tools in the description:\n- get_weather\ + \ — Get the current weather for a city\n- get_exchange_rate — Get the\ + \ exchange rate between two currencies\n- search_hotels — Search for hotels\ + \ in a city\n- travel — Travel location tools.\n\nI will formulate the\ + \ query to request a tool for current weather and a tool for travel time\ + \ zone.\nQuery: \"a function to get current weather and a function to\ + \ get travel time zones\"\nThen I will call `tool_search` with this query.\n\ + Let's double check the constraints: \"First call tool_search exactly once...\ + \ Do not call any other tool yet.\"\nProceed. \nI will construct the query\ + \ carefully.\n`tool_search(query=\"a function to get current weather and\ + \ a function for travel time zones\")`\nDone. \nWait, let's verify the\ + \ exact description. \"travel — Travel location tools.\" Time zone might\ + \ be part of travel tools.\nI'll just ask for \"current weather and travel\ + \ time zone capabilities\".\nReady. \nCalling tool_search. \nNote: The\ + \ prompt says \"First call tool_search exactly once...\". So I will output\ + \ the tool call now. \nLet's make sure the parameters match: `{\"query\"\ + : \"string\"}`.\nQuery: \"a current-weather function and a travel time-zone\ + \ function\"\nProceeds. \nOutput matches format.✅\n" + type: reasoning_text + encrypted_content: null + id: rs_9e94d568ce77b3e8 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "a current-weather function and a travel time-zone function"}' + call_id: chatcmpl-tool-bfd01690188cd378 + caller: null + id: fc_a54b71918970eabe + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-bfd01690188cd378 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + any other tool. + role: user + type: message + - content: + - text: 'The user wants me to call the `get_weather` function with the argument + `{"city": "Paris"}`. - I will call `get_weather(city="Paris")`. + I should not call any other tool. - No tool_search call will be made. + I have found the `get_weather` function in the previous step. - Done. + I will proceed with calling `get_weather(city="Paris")`. ' type: reasoning_text encrypted_content: null - id: rs_93c3775bd42c986b + id: rs_8b055887860d4070 status: null summary: [] type: reasoning - arguments: '{"city": "Paris"}' - call_id: chatcmpl-tool-8479880974982f23 + call_id: chatcmpl-tool-b8e069e468543530 caller: null - id: fc_87049b418f2fe733 + id: fc_92d8c37016c26805 + name: get_weather + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-b8e069e468543530 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: + name: agentic_ns__travel__get_timezone + type: function + tools: + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capabilities. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + created_at: 1787714227 + frequency_penalty: 0.0 + id: resp_a4bbf113c68a2596 + incomplete_details: null + input_messages: null + instructions: null + kv_transfer_params: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to call the `get_timezone` function, which is part + of the `travel` namespace, with the parameter `{"city": "Paris"}`. + + I should use the `agentic_ns__travel__get_timezone` function. + + The parameter is `city: "Paris"`. + + I will make exactly one tool call. + + No other tools should be called. + + ' + type: reasoning_text + encrypted_content: null + id: rs_a5943cde7bd96f68 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-9f31ea6d5523f168 + caller: null + id: fc_aed60407fd0eaf05 + name: agentic_ns__travel__get_timezone namespace: null status: completed type: function_call @@ -279,19 +573,23 @@ turns: status: completed temperature: 1.0 text: null - tool_choice: auto + tool_choice: + name: agentic_ns__travel__get_timezone + type: function tools: - allowed_callers: null defer_loading: null - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' name: tool_search output_schema: null parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query @@ -300,7 +598,7 @@ turns: type: function - allowed_callers: null defer_loading: null - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather output_schema: null parameters: @@ -313,113 +611,159 @@ turns: type: object strict: true type: function + - allowed_callers: null + defer_loading: null + description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function top_logprobs: null top_p: 0.95 truncation: disabled usage: - input_tokens: 562 + input_tokens: 862 input_tokens_details: cached_tokens: 0 cached_tokens_per_turn: [] input_tokens_per_turn: [] - output_tokens: 140 + output_tokens: 115 output_tokens_details: output_tokens_per_turn: [] reasoning_tokens: 0 tool_output_tokens: 0 tool_output_tokens_per_turn: [] - total_tokens: 702 + total_tokens: 977 user: null headers: content-type: application/json status_code: 200 -- filename: t3 +- filename: t4 request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message - content: - - text: "The user wants me to call `tool_search` exactly once to find a weather\ - \ tool.\nI should use the `tool_search` function with a query like \"\ - weather\" or \"current weather\".\nThe description says: \"Get the current\ - \ weather for a city.\"\nI will call `tool_search` with query \"weather\ - \ tool\".\nAfter this, I will not call `get_weather` yet as per instructions.\n\ - Let's proceed. \nQuery: \"weather\" or \"get weather\"\nParameters: {\"\ - query\": \"weather\"}\nTool: tool_search\nStrict: true.\nDone. \nProceeding.\ - \ \n`tool_search(query=\"weather\")`\nOutput matches requirement.\nWait,\ - \ let's verify the exact parameters. `tool_search` requires `query`.\n\ - I will call it now. \nResult: `tool_search(query=\"weather\")`\nNo other\ - \ calls.\nDone. \nLet's generate the tool call. \n[Tool Call]\ntool_search(query=\"\ - weather\")\n[End] \nProceeds. \n(Self-Correction/Verification during thought)\n\ - The prompt says: \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just call `tool_search` with\ - \ a relevant query.\nQuery: \"weather tool\"\nAll good.\nProceeds. \n\ - Output matches.\n" + - text: "The user wants me to call `tool_search` exactly once to find:\n1.\ + \ A current-weather function\n2. A travel time-zone function\n\nI need\ + \ to construct a query that asks for both of these capabilities in a single\ + \ search.\nLooking at the available tools in the description:\n- get_weather\ + \ — Get the current weather for a city\n- get_exchange_rate — Get the\ + \ exchange rate between two currencies\n- search_hotels — Search for hotels\ + \ in a city\n- travel — Travel location tools.\n\nI will formulate the\ + \ query to request a tool for current weather and a tool for travel time\ + \ zone.\nQuery: \"a function to get current weather and a function to\ + \ get travel time zones\"\nThen I will call `tool_search` with this query.\n\ + Let's double check the constraints: \"First call tool_search exactly once...\ + \ Do not call any other tool yet.\"\nProceed. \nI will construct the query\ + \ carefully.\n`tool_search(query=\"a function to get current weather and\ + \ a function for travel time zones\")`\nDone. \nWait, let's verify the\ + \ exact description. \"travel — Travel location tools.\" Time zone might\ + \ be part of travel tools.\nI'll just ask for \"current weather and travel\ + \ time zone capabilities\".\nReady. \nCalling tool_search. \nNote: The\ + \ prompt says \"First call tool_search exactly once...\". So I will output\ + \ the tool call now. \nLet's make sure the parameters match: `{\"query\"\ + : \"string\"}`.\nQuery: \"a current-weather function and a travel time-zone\ + \ function\"\nProceeds. \nOutput matches format.✅\n" type: reasoning_text encrypted_content: null - id: rs_86d01db313ae56e6 + id: rs_9e94d568ce77b3e8 status: null summary: [] type: reasoning - - arguments: '{"query": "weather tool"}' - call_id: chatcmpl-tool-aca6cd11d99339b4 + - arguments: '{"query": "a current-weather function and a travel time-zone function"}' + call_id: chatcmpl-tool-bfd01690188cd378 caller: null - id: fc_9ee5ffe95a32e0b8 + id: fc_a54b71918970eabe name: tool_search namespace: null status: completed type: function_call - - call_id: chatcmpl-tool-aca6cd11d99339b4 + - call_id: chatcmpl-tool-bfd01690188cd378 output: '{"tools":[{"defer_loading":true,"description":"Get the current weather - for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' type: function_call_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message - content: - - text: 'The user wants me to call `get_weather` with the city "Paris". + - text: 'The user wants me to call the `get_weather` function with the argument + `{"city": "Paris"}`. - The instruction specifies "exactly once" and "Do not call tool_search - again". + I should not call any other tool. - I have found the tool `get_weather` in the previous step. + I have found the `get_weather` function in the previous step. - I need to construct the function call with the parameter `{"city": "Paris"}`. + I will proceed with calling `get_weather(city="Paris")`. - The tool signature is `get_weather(city: string)`. + ' + type: reasoning_text + encrypted_content: null + id: rs_8b055887860d4070 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-b8e069e468543530 + caller: null + id: fc_92d8c37016c26805 + name: get_weather + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-b8e069e468543530 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + - content: + - text: 'The user wants me to call the `get_timezone` function, which is part + of the `travel` namespace, with the parameter `{"city": "Paris"}`. - The parameter matches. + I should use the `agentic_ns__travel__get_timezone` function. - I will call `get_weather(city="Paris")`. + The parameter is `city: "Paris"`. - No tool_search call will be made. + I will make exactly one tool call. - Done. + No other tools should be called. ' type: reasoning_text encrypted_content: null - id: rs_93c3775bd42c986b + id: rs_a5943cde7bd96f68 status: null summary: [] type: reasoning - arguments: '{"city": "Paris"}' - call_id: chatcmpl-tool-8479880974982f23 + call_id: chatcmpl-tool-9f31ea6d5523f168 caller: null - id: fc_87049b418f2fe733 - name: get_weather + id: fc_aed60407fd0eaf05 + name: agentic_ns__travel__get_timezone namespace: null status: completed type: function_call - - call_id: chatcmpl-tool-8479880974982f23 - output: '{"city":"Paris","condition":"clear","temperature_c":21}' + - call_id: chatcmpl-tool-9f31ea6d5523f168 + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. role: user type: message max_output_tokens: 4096 @@ -427,23 +771,25 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: none tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' name: tool_search parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object strict: true type: function - - description: Get the current weather for a city. + - description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -455,6 +801,18 @@ turns: type: object strict: true type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function headers: accept: '*/*' content-type: application/json @@ -465,9 +823,9 @@ turns: response: body: background: false - created_at: 1787143224 + created_at: 1787714228 frequency_penalty: 0.0 - id: resp_b8ebf173c78f5ec6 + id: resp_937efeb59f115cd7 incomplete_details: null input_messages: null instructions: null @@ -479,20 +837,18 @@ turns: object: response output: - content: - - text: 'The user wants me to call no more tools and reply with a specific - string based on the previous tool output. - - I already called `get_weather` and got the result `{"city":"Paris","condition":"clear","temperature_c":21}`. - - The user''s instruction is: "Use the function output and call no more - tools. Reply with exactly PARIS_WEATHER_OK." - - So I will just output "PARIS_WEATHER_OK". - - ' + - text: "The user wants a final output based on the previous tool calls.\n\ + I called `get_weather` for Paris and got the weather.\nI called `get_timezone`\ + \ (via `travel`) for Paris and got the timezone.\nThe instruction is to\ + \ reply with exactly \"PARIS_MIXED_TOOLS_OK\".\nNo more tools should be\ + \ called.\n\nSteps:\n1. Check the outputs from previous steps.\n *\ + \ Weather: Clear, 21C.\n * Timezone: Europe/Paris.\n2. Formulate\ + \ the response.\n3. The response must be exactly \"PARIS_MIXED_TOOLS_OK\"\ + .\n\nConstraint Check:\n* Call no more tools? Yes.\n* Reply with exactly\ + \ PARIS_MIXED_TOOLS_OK? Yes.\n\nFinal Output Generation:\nPARIS_MIXED_TOOLS_OK\n" type: reasoning_text encrypted_content: null - id: rs_b6140caa04ab505a + id: rs_99e8c06239fd895c status: null summary: [] type: reasoning @@ -502,9 +858,9 @@ turns: text: ' - PARIS_WEATHER_OK' + PARIS_MIXED_TOOLS_OK' type: output_text - id: msg_bf2b600626377798 + id: msg_93620139bdc851d1 phase: null role: assistant status: completed @@ -519,19 +875,21 @@ turns: status: completed temperature: 1.0 text: null - tool_choice: auto + tool_choice: none tools: - allowed_callers: null defer_loading: null - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' name: tool_search output_schema: null parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query @@ -540,7 +898,7 @@ turns: type: function - allowed_callers: null defer_loading: null - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather output_schema: null parameters: @@ -553,22 +911,37 @@ turns: type: object strict: true type: function + - allowed_callers: null + defer_loading: null + description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function top_logprobs: null top_p: 0.95 truncation: disabled usage: - input_tokens: 639 + input_tokens: 407 input_tokens_details: cached_tokens: 0 cached_tokens_per_turn: [] input_tokens_per_turn: [] - output_tokens: 99 + output_tokens: 189 output_tokens_details: output_tokens_per_turn: [] reasoning_tokens: 0 tool_output_tokens: 0 tool_output_tokens_per_turn: [] - total_tokens: 738 + total_tokens: 596 user: null headers: content-type: application/json diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml index 955a2e51..3d53cfe4 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml @@ -3,8 +3,8 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message max_output_tokens: 4096 @@ -12,16 +12,20 @@ turns: parallel_tool_calls: false store: false stream: true - tool_choice: auto + tool_choice: + name: tool_search + type: function tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather + for a city; get_exchange_rate — Get the exchange rate between two currencies; + search_hotels — Search for hotels in a city; travel — Travel location tools.' name: tool_search parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query @@ -42,10 +46,12 @@ turns: - 'event: response.created ' - - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + - 'data: {"response":{"id":"resp_b003f8b489a32767","created_at":1787714231,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"tool_search","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_weather — Get the current weather for a city; get_exchange_rate + — Get the exchange rate between two currencies; search_hotels — Search for hotels + in a city; travel — Travel location tools.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} ' - ' @@ -54,10 +60,12 @@ turns: - 'event: response.in_progress ' - - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + - 'data: {"response":{"id":"resp_b003f8b489a32767","created_at":1787714231,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"tool_search","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_weather — Get the current weather for a city; get_exchange_rate + — Get the exchange rate between two currencies; search_hotels — Search for hotels + in a city; travel — Travel location tools.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} ' - ' @@ -66,7 +74,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"item":{"id":"abb83b715b53c8ad","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + - 'data: {"item":{"id":"88b2decbae922433","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} ' - ' @@ -75,7 +83,7 @@ turns: - 'event: response.reasoning_part.added ' - - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + - 'data: {"content_index":0,"item_id":"88b2decbae922433","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} ' - ' @@ -84,7 +92,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"The","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"The","item_id":"88b2decbae922433","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} ' - ' @@ -93,7 +101,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" user wants me","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" user wants me","item_id":"88b2decbae922433","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} ' - ' @@ -102,7 +110,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to call","item_id":"88b2decbae922433","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} ' - ' @@ -111,7 +119,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `tool_search","item_id":"88b2decbae922433","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} ' - ' @@ -120,7 +128,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` exactly once","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"` exactly once","item_id":"88b2decbae922433","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} ' - ' @@ -129,7 +137,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to find a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".","item_id":"88b2decbae922433","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} ' - ' @@ -138,7 +146,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" weather tool.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nThe query","item_id":"88b2decbae922433","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} ' - ' @@ -147,7 +155,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nI","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" needs","item_id":"88b2decbae922433","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} ' - ' @@ -156,7 +164,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" should not call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to find two","item_id":"88b2decbae922433","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} ' - ' @@ -165,7 +173,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `get_weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" specific","item_id":"88b2decbae922433","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} ' - ' @@ -174,7 +182,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` yet.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" capabilities:\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} ' - ' @@ -183,7 +191,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nThe","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"1. A","item_id":"88b2decbae922433","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} ' - ' @@ -192,7 +200,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" description","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" current-weather","item_id":"88b2decbae922433","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} ' - ' @@ -201,7 +209,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" for `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function.\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} ' - ' @@ -210,7 +218,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_search` says","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"2. A","item_id":"88b2decbae922433","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} ' - ' @@ -219,7 +227,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": \"Search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel time-zone","item_id":"88b2decbae922433","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} ' - ' @@ -228,7 +236,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the client tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function.\n\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} ' - ' @@ -237,7 +245,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" catalog. Available","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"I will formulate","item_id":"88b2decbae922433","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} ' - ' @@ -246,7 +254,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" catalog entry:","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the query","item_id":"88b2decbae922433","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} ' - ' @@ -255,7 +263,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" get_weather —","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to include","item_id":"88b2decbae922433","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} ' - ' @@ -264,7 +272,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" Get the current","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" both requests","item_id":"88b2decbae922433","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} ' - ' @@ -273,7 +281,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" weather for a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\nQuery","item_id":"88b2decbae922433","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} ' - ' @@ -282,7 +290,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" city.\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": \"current","item_id":"88b2decbae922433","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} ' - ' @@ -291,7 +299,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"I need","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" weather function and","item_id":"88b2decbae922433","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} ' - ' @@ -300,7 +308,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to provide","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel time-zone","item_id":"88b2decbae922433","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} ' - ' @@ -309,7 +317,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" a query for","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function\"\n\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} ' - ' @@ -318,7 +326,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Then","item_id":"88b2decbae922433","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} ' - ' @@ -327,7 +335,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool search.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" I will call","item_id":"88b2decbae922433","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} ' - ' @@ -336,7 +344,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nQuery:","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `","item_id":"88b2decbae922433","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} ' - ' @@ -345,7 +353,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \"find","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"tool_search`.","item_id":"88b2decbae922433","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} ' - ' @@ -354,7 +362,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" a weather tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nI","item_id":"88b2decbae922433","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} ' - ' @@ -363,7 +371,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" must","item_id":"88b2decbae922433","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} ' - ' @@ -372,7 +380,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"I will call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" not call any","item_id":"88b2decbae922433","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} ' - ' @@ -381,7 +389,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" other tool","item_id":"88b2decbae922433","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} ' - ' @@ -390,7 +398,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` with this","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".","item_id":"88b2decbae922433","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} ' - ' @@ -399,7 +407,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" query.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} ' - ' @@ -408,7 +416,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"Wait","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"The available","item_id":"88b2decbae922433","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} ' - ' @@ -417,7 +425,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":", the prompt","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tools in","item_id":"88b2decbae922433","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} ' - ' @@ -426,7 +434,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" says \"First","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the description are","item_id":"88b2decbae922433","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} ' - ' @@ -435,7 +443,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" call tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": get","item_id":"88b2decbae922433","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} ' - ' @@ -444,7 +452,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" exactly once to","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_weather, get","item_id":"88b2decbae922433","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} ' - ' @@ -453,7 +461,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" find a weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_exchange_rate,","item_id":"88b2decbae922433","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} ' - ' @@ -462,7 +470,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" search_hotels","item_id":"88b2decbae922433","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} ' - ' @@ -471,7 +479,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" Do not call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":", travel.","item_id":"88b2decbae922433","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} ' - ' @@ -480,7 +488,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" get_weather yet","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\n\"","item_id":"88b2decbae922433","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} ' - ' @@ -489,7 +497,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\"\nSo","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_weather\"","item_id":"88b2decbae922433","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} ' - ' @@ -498,7 +506,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" I just","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" fits","item_id":"88b2decbae922433","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} ' - ' @@ -507,7 +515,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" \"","item_id":"88b2decbae922433","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} ' - ' @@ -516,7 +524,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"current-","item_id":"88b2decbae922433","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} ' - ' @@ -525,7 +533,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"`.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"weather function","item_id":"88b2decbae922433","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} ' - ' @@ -534,7 +542,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nLet''s","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\".\n\"","item_id":"88b2decbae922433","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} ' - ' @@ -543,7 +551,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" do it","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"travel\" fits","item_id":"88b2decbae922433","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} ' - ' @@ -552,7 +560,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\nQuery","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" \"travel time","item_id":"88b2decbae922433","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} ' - ' @@ -561,7 +569,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": \"weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"-zone function\"","item_id":"88b2decbae922433","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} ' - ' @@ -570,7 +578,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool\"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" (or similar","item_id":"88b2decbae922433","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} ' - ' @@ -579,7 +587,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"Parameters","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel","item_id":"88b2decbae922433","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} ' - ' @@ -588,7 +596,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": `{\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" capability","item_id":"88b2decbae922433","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} ' - ' @@ -597,7 +605,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"query\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":").\n\nI","item_id":"88b2decbae922433","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} ' - ' @@ -606,7 +614,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" will construct the","item_id":"88b2decbae922433","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} ' - ' @@ -615,7 +623,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"}`\nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tool call","item_id":"88b2decbae922433","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} ' - ' @@ -624,7 +632,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \nChecking","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" now","item_id":"88b2decbae922433","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} ' - ' @@ -633,7 +641,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" strict","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\nTool","item_id":"88b2decbae922433","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} ' - ' @@ -642,7 +650,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"ness","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": tool","item_id":"88b2decbae922433","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} ' - ' @@ -651,7 +659,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": `","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_search\nParameters","item_id":"88b2decbae922433","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} ' - ' @@ -660,7 +668,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"allowed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": query=\"","item_id":"88b2decbae922433","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} ' - ' @@ -669,7 +677,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_callers`","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"current-","item_id":"88b2decbae922433","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} ' - ' @@ -678,7 +686,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" is null,","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"weather function and","item_id":"88b2decbae922433","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} ' - ' @@ -687,7 +695,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `defer_loading","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel time-zone","item_id":"88b2decbae922433","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} ' - ' @@ -696,7 +704,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` is null","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function\"\n\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} ' - ' @@ -705,7 +713,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". Only","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Let''s double","item_id":"88b2decbae922433","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} ' - ' @@ -714,7 +722,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" check the requirements","item_id":"88b2decbae922433","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} ' - ' @@ -723,7 +731,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_search` is","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\n-","item_id":"88b2decbae922433","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} ' - ' @@ -732,7 +740,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" available.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" Call","item_id":"88b2decbae922433","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} ' - ' @@ -741,7 +749,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"Proceed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tool_search exactly","item_id":"88b2decbae922433","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} ' - ' @@ -750,7 +758,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \nOutput","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" once.\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} ' - ' @@ -759,7 +767,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" matches the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"- Find","item_id":"88b2decbae922433","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} ' - ' @@ -768,7 +776,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" format","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" a","item_id":"88b2decbae922433","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} ' - ' @@ -777,7 +785,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\nI","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" current-weather","item_id":"88b2decbae922433","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} ' - ' @@ -786,7 +794,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" will generate the","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function and a","item_id":"88b2decbae922433","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} ' - ' @@ -795,7 +803,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool call.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel time-zone","item_id":"88b2decbae922433","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} ' - ' @@ -804,7 +812,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \nNote","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function.\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} ' - ' @@ -813,7 +821,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": I","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"- Do not","item_id":"88b2decbae922433","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} ' - ' @@ -822,7 +830,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" should","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" call any other","item_id":"88b2decbae922433","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} ' - ' @@ -831,7 +839,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" just output","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tool yet.","item_id":"88b2decbae922433","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} ' - ' @@ -840,7 +848,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the tool call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\n\nThe query","item_id":"88b2decbae922433","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} ' - ' @@ -849,7 +857,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\n```","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" string","item_id":"88b2decbae922433","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} ' - ' @@ -858,7 +866,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"json\n{","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" should be descriptive","item_id":"88b2decbae922433","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} ' - ' @@ -867,7 +875,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" enough.","item_id":"88b2decbae922433","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} ' - ' @@ -876,7 +884,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"name\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nQuery","item_id":"88b2decbae922433","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} ' - ' @@ -885,7 +893,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"tool_search\",","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": \"current","item_id":"88b2decbae922433","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} ' - ' @@ -894,7 +902,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" weather function and","item_id":"88b2decbae922433","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} ' - ' @@ -903,7 +911,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"parameters\": {","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" travel time-zone","item_id":"88b2decbae922433","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} ' - ' @@ -912,7 +920,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" function\" looks","item_id":"88b2decbae922433","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} ' - ' @@ -921,7 +929,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"query\": \"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" good.\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} ' - ' @@ -930,7 +938,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"I will proceed","item_id":"88b2decbae922433","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} ' - ' @@ -939,7 +947,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n }","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" with the tool","item_id":"88b2decbae922433","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} ' - ' @@ -948,151 +956,376 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n}\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" call.\n","item_id":"88b2decbae922433","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.reasoning_text.done ' - - 'data: {"content_index":0,"delta":"```\nActually","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"item_id":"88b2decbae922433","output_index":0,"sequence_number":101,"text":"The + user wants me to call `tool_search` exactly once.\nThe query needs to find two + specific capabilities:\n1. A current-weather function.\n2. A travel time-zone + function.\n\nI will formulate the query to include both requests.\nQuery: \"current + weather function and travel time-zone function\"\n\nThen I will call `tool_search`.\nI + must not call any other tool.\nThe available tools in the description are: get_weather, + get_exchange_rate, search_hotels, travel.\n\"get_weather\" fits \"current-weather + function\".\n\"travel\" fits \"travel time-zone function\" (or similar travel + capability).\n\nI will construct the tool call now.\nTool: tool_search\nParameters: + query=\"current-weather function and travel time-zone function\"\n\nLet''s double + check the requirements.\n- Call tool_search exactly once.\n- Find a current-weather + function and a travel time-zone function.\n- Do not call any other tool yet.\n\nThe + query string should be descriptive enough.\nQuery: \"current weather function + and travel time-zone function\" looks good.\nI will proceed with the tool call.\n","type":"response.reasoning_text.done"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.reasoning_part.done ' - - 'data: {"content_index":0,"delta":", the prompt","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"item_id":"88b2decbae922433","output_index":0,"part":{"text":"The + user wants me to call `tool_search` exactly once.\nThe query needs to find two + specific capabilities:\n1. A current-weather function.\n2. A travel time-zone + function.\n\nI will formulate the query to include both requests.\nQuery: \"current + weather function and travel time-zone function\"\n\nThen I will call `tool_search`.\nI + must not call any other tool.\nThe available tools in the description are: get_weather, + get_exchange_rate, search_hotels, travel.\n\"get_weather\" fits \"current-weather + function\".\n\"travel\" fits \"travel time-zone function\" (or similar travel + capability).\n\nI will construct the tool call now.\nTool: tool_search\nParameters: + query=\"current-weather function and travel time-zone function\"\n\nLet''s double + check the requirements.\n- Call tool_search exactly once.\n- Find a current-weather + function and a travel time-zone function.\n- Do not call any other tool yet.\n\nThe + query string should be descriptive enough.\nQuery: \"current weather function + and travel time-zone function\" looks good.\nI will proceed with the tool call.\n","type":"reasoning_text"},"sequence_number":102,"type":"response.reasoning_part.done"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.output_item.done ' - - 'data: {"content_index":0,"delta":" says \"First","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} + - 'data: {"item":{"id":"88b2decbae922433","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `tool_search` exactly once.\nThe query needs to find two + specific capabilities:\n1. A current-weather function.\n2. A travel time-zone + function.\n\nI will formulate the query to include both requests.\nQuery: \"current + weather function and travel time-zone function\"\n\nThen I will call `tool_search`.\nI + must not call any other tool.\nThe available tools in the description are: get_weather, + get_exchange_rate, search_hotels, travel.\n\"get_weather\" fits \"current-weather + function\".\n\"travel\" fits \"travel time-zone function\" (or similar travel + capability).\n\nI will construct the tool call now.\nTool: tool_search\nParameters: + query=\"current-weather function and travel time-zone function\"\n\nLet''s double + check the requirements.\n- Call tool_search exactly once.\n- Find a current-weather + function and a travel time-zone function.\n- Do not call any other tool yet.\n\nThe + query string should be descriptive enough.\nQuery: \"current weather function + and travel time-zone function\" looks good.\nI will proceed with the tool call.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":103,"type":"response.output_item.done"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.output_item.added ' - - 'data: {"content_index":0,"delta":" call tool_search","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} + - 'data: {"item":{"arguments":"","call_id":"call_b5c84c9e6e121266","name":"tool_search","type":"function_call","id":"94a34026233462e0","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":104,"type":"response.output_item.added"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":" exactly once to","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} + - 'data: {"delta":"{\"query\": \"current","item_id":"94a34026233462e0","output_index":1,"sequence_number":105,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":" find a weather","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} + - 'data: {"delta":"-","item_id":"94a34026233462e0","output_index":1,"sequence_number":106,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":" tool.\" I","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} + - 'data: {"delta":"weather function and","item_id":"94a34026233462e0","output_index":1,"sequence_number":107,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":"''ll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} + - 'data: {"delta":" travel time-zone","item_id":"94a34026233462e0","output_index":1,"sequence_number":108,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":" just make","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} + - 'data: {"delta":" function","item_id":"94a34026233462e0","output_index":1,"sequence_number":109,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.delta ' - - 'data: {"content_index":0,"delta":" the call.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} + - 'data: {"delta":"\"}","item_id":"94a34026233462e0","output_index":1,"sequence_number":110,"type":"response.function_call_arguments.delta"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.function_call_arguments.done ' - - 'data: {"content_index":0,"delta":"\nDone.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} + - 'data: {"arguments":"{\"query\": \"current-weather function and travel time-zone + function\"}","item_id":"94a34026233462e0","name":"tool_search","output_index":1,"sequence_number":111,"type":"response.function_call_arguments.done"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.output_item.done ' - - 'data: {"content_index":0,"delta":" \nWait","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} + - 'data: {"item":{"arguments":"{\"query\": \"current-weather function and travel + time-zone function\"}","call_id":"call_b5c84c9e6e121266","name":"tool_search","type":"function_call","id":"94a34026233462e0","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":112,"type":"response.output_item.done"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.completed ' - - 'data: {"content_index":0,"delta":", let''s","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} + - 'data: {"response":{"id":"resp_b003f8b489a32767","created_at":1787714231,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_a7bd7fe3378ba674","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `tool_search` exactly once.\nThe query needs to find two + specific capabilities:\n1. A current-weather function.\n2. A travel time-zone + function.\n\nI will formulate the query to include both requests.\nQuery: \"current + weather function and travel time-zone function\"\n\nThen I will call `tool_search`.\nI + must not call any other tool.\nThe available tools in the description are: get_weather, + get_exchange_rate, search_hotels, travel.\n\"get_weather\" fits \"current-weather + function\".\n\"travel\" fits \"travel time-zone function\" (or similar travel + capability).\n\nI will construct the tool call now.\nTool: tool_search\nParameters: + query=\"current-weather function and travel time-zone function\"\n\nLet''s double + check the requirements.\n- Call tool_search exactly once.\n- Find a current-weather + function and a travel time-zone function.\n- Do not call any other tool yet.\n\nThe + query string should be descriptive enough.\nQuery: \"current weather function + and travel time-zone function\" looks good.\nI will proceed with the tool call.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"query\": + \"current-weather function and travel time-zone function\"}","call_id":"chatcmpl-tool-832a4075db59f392","name":"tool_search","type":"function_call","id":"fc_8543ff56a05e2fc7","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"tool_search","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_weather — Get the current weather for a city; get_exchange_rate + — Get the exchange rate between two currencies; search_hotels — Search for hotels + in a city; travel — Travel location tools.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":394,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":278,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":672},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":113,"type":"response.completed"} ' - ' ' - - 'event: response.reasoning_text.delta + status_code: 200 +- filename: t2 + request: + body: + input: + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. + role: user + type: message + - content: + - text: 'The user wants me to call `tool_search` exactly once. + + The query needs to find two specific capabilities: + + 1. A current-weather function. + + 2. A travel time-zone function. + + + I will formulate the query to include both requests. + + Query: "current weather function and travel time-zone function" + + + Then I will call `tool_search`. + + I must not call any other tool. + + The available tools in the description are: get_weather, get_exchange_rate, + search_hotels, travel. + + "get_weather" fits "current-weather function". + + "travel" fits "travel time-zone function" (or similar travel capability). + + + I will construct the tool call now. + + Tool: tool_search + + Parameters: query="current-weather function and travel time-zone function" + + + Let''s double check the requirements. + + - Call tool_search exactly once. + + - Find a current-weather function and a travel time-zone function. + + - Do not call any other tool yet. + + + The query string should be descriptive enough. + + Query: "current weather function and travel time-zone function" looks + good. + + I will proceed with the tool call. + + ' + type: reasoning_text + encrypted_content: null + id: rs_a7bd7fe3378ba674 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "current-weather function and travel time-zone function"}' + call_id: chatcmpl-tool-832a4075db59f392 + caller: null + id: fc_8543ff56a05e2fc7 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-832a4075db59f392 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: true + tool_choice: + name: get_weather + type: function + tools: + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capabilities. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: + - 'event: response.created ' - - 'data: {"content_index":0,"delta":" verify the schema","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} + - 'data: {"response":{"id":"resp_a1f440f71446236e","created_at":1787714233,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.in_progress ' - - 'data: {"content_index":0,"delta":":","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} + - 'data: {"response":{"id":"resp_a1f440f71446236e","created_at":1787714233,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} ' - ' ' - - 'event: response.reasoning_text.delta + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"be7af246b8d8923b","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - ' ' - - 'data: {"content_index":0,"delta":" `tool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} + - 'event: response.reasoning_part.added + + ' + - 'data: {"content_index":0,"item_id":"be7af246b8d8923b","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} ' - ' @@ -1101,7 +1334,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_search` takes","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"The","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} ' - ' @@ -1110,7 +1343,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `query`","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" user wants me","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} ' - ' @@ -1119,7 +1352,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" (string).","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to call `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} ' - ' @@ -1128,7 +1361,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nAll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_weather`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} ' - ' @@ -1137,7 +1370,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" good.\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" with the city","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} ' - ' @@ -1146,7 +1379,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"Proceed.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" \"Paris\".","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} ' - ' @@ -1155,7 +1388,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \nOutput matches","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nI need","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} ' - ' @@ -1164,7 +1397,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to check","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} ' - ' @@ -1173,7 +1406,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \n(Self","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the available","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} ' - ' @@ -1182,7 +1415,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"-Correction","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tools to make","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} ' - ' @@ -1191,7 +1424,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"/Ref","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" sure `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} ' - ' @@ -1200,7 +1433,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"inement during thought","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_weather`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} ' - ' @@ -1209,7 +1442,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":")\nShould","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" is available.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} ' - ' @@ -1218,7 +1451,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" I be","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nFrom the","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} ' - ' @@ -1227,7 +1460,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" more specific in","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" previous tool_search","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} ' - ' @@ -1236,7 +1469,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the query?","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" result:","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} ' - ' @@ -1245,7 +1478,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \"find a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\n-","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} ' - ' @@ -1254,7 +1487,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" weather tool\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `get_weather","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} ' - ' @@ -1263,7 +1496,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" is fine.","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"` is available","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} ' - ' @@ -1272,7 +1505,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nI will","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" with description \"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} ' - ' @@ -1281,7 +1514,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" generate","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Get the current","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} ' - ' @@ -1290,7 +1523,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the call","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" weather for a","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} ' - ' @@ -1299,7 +1532,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" city\",","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} ' - ' @@ -1308,7 +1541,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":140,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" parameters: city","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} ' - ' @@ -1317,7 +1550,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \nProceed","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":141,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" (string).","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} ' - ' @@ -1326,7 +1559,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"ing. \n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":142,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\n- `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} ' - ' @@ -1335,7 +1568,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"[Output Generation","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":143,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"travel` is","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} ' - ' @@ -1344,7 +1577,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"]\ntool","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":144,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" a namespace containing","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} ' - ' @@ -1353,7 +1586,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_search(query=\"","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":145,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `get_timezone","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} ' - ' @@ -1362,7 +1595,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"weather tool\")","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":146,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"`.\n\nThe","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} ' - ' @@ -1371,7 +1604,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nDone","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":147,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" user''s","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} ' - ' @@ -1380,7 +1613,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":". \n(Note","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":148,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" request","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} ' - ' @@ -1389,7 +1622,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": I''ll","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":149,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" is straightforward","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} ' - ' @@ -1398,7 +1631,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" format it correctly","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":150,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": call `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} ' - ' @@ -1407,7 +1640,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" as a","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":151,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_weather`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} ' - ' @@ -1416,7 +1649,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool call)","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":152,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" with","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} ' - ' @@ -1425,314 +1658,133 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\n","item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":153,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `{\"city","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_text.done + - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"sequence_number":154,"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nI - should not call `get_weather` yet.\nThe description for `tool_search` says: - \"Search the client tool catalog. Available catalog entry: get_weather — Get - the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: - \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the - prompt says \"First call tool_search exactly once to find a weather tool. Do - not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: - \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking - strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` - is available.\nProceed. \nOutput matches the format.\nI will generate the tool - call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": - {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First - call tool_search exactly once to find a weather tool.\" I''ll just make the - call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll - good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during - thought)\nShould I be more specific in the query? \"find a weather tool\" is - fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather - tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"response.reasoning_text.done"} + - 'data: {"content_index":0,"delta":"\": \"Paris","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_part.done + - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"item_id":"abb83b715b53c8ad","output_index":0,"part":{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nI - should not call `get_weather` yet.\nThe description for `tool_search` says: - \"Search the client tool catalog. Available catalog entry: get_weather — Get - the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: - \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the - prompt says \"First call tool_search exactly once to find a weather tool. Do - not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: - \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking - strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` - is available.\nProceed. \nOutput matches the format.\nI will generate the tool - call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": - {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First - call tool_search exactly once to find a weather tool.\" I''ll just make the - call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll - good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during - thought)\nShould I be more specific in the query? \"find a weather tool\" is - fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather - tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"},"sequence_number":155,"type":"response.reasoning_part.done"} + - 'data: {"content_index":0,"delta":"\"}`.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"id":"abb83b715b53c8ad","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nI - should not call `get_weather` yet.\nThe description for `tool_search` says: - \"Search the client tool catalog. Available catalog entry: get_weather — Get - the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: - \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the - prompt says \"First call tool_search exactly once to find a weather tool. Do - not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: - \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking - strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` - is available.\nProceed. \nOutput matches the format.\nI will generate the tool - call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": - {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First - call tool_search exactly once to find a weather tool.\" I''ll just make the - call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll - good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during - thought)\nShould I be more specific in the query? \"find a weather tool\" is - fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather - tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":156,"type":"response.output_item.done"} + - 'data: {"content_index":0,"delta":"\nI will","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"arguments":"","call_id":"call_85ac6429f2576051","name":"tool_search","type":"function_call","id":"b388db0be9780d3a","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":157,"type":"response.output_item.added"} + - 'data: {"content_index":0,"delta":" proceed","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"{\"query\": \"","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":158,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":" with the","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"weather tool","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":159,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":" function","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"\"}","item_id":"b388db0be9780d3a","output_index":1,"sequence_number":160,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":" call.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.done + - 'event: response.reasoning_text.delta ' - - 'data: {"arguments":"{\"query\": \"weather tool\"}","item_id":"b388db0be9780d3a","name":"tool_search","output_index":1,"sequence_number":161,"type":"response.function_call_arguments.done"} + - 'data: {"content_index":0,"delta":"No other tools","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"arguments":"{\"query\": \"weather tool\"}","call_id":"call_85ac6429f2576051","name":"tool_search","type":"function_call","id":"b388db0be9780d3a","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":162,"type":"response.output_item.done"} + - 'data: {"content_index":0,"delta":" should be called","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.completed + - 'event: response.reasoning_text.delta ' - - 'data: {"response":{"id":"resp_95ce5dc1e26dc70f","created_at":1787143227,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_9327d10a4a99e129","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nI - should not call `get_weather` yet.\nThe description for `tool_search` says: - \"Search the client tool catalog. Available catalog entry: get_weather — Get - the current weather for a city.\"\nI need to provide a query for the tool search.\nQuery: - \"find a weather tool\"\nI will call `tool_search` with this query.\nWait, the - prompt says \"First call tool_search exactly once to find a weather tool. Do - not call get_weather yet.\"\nSo I just call `tool_search`.\nLet''s do it.\nQuery: - \"weather tool\"\nParameters: `{\"query\": \"weather tool\"}`\nDone. \nChecking - strictness: `allowed_callers` is null, `defer_loading` is null. Only `tool_search` - is available.\nProceed. \nOutput matches the format.\nI will generate the tool - call. \nNote: I should just output the tool call.\n```json\n{\n \"name\": \"tool_search\",\n \"parameters\": - {\n \"query\": \"weather tool\"\n }\n}\n```\nActually, the prompt says \"First - call tool_search exactly once to find a weather tool.\" I''ll just make the - call.\nDone. \nWait, let''s verify the schema: `tool_search` takes `query` (string).\nAll - good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement during - thought)\nShould I be more specific in the query? \"find a weather tool\" is - fine.\nI will generate the call. \nDone. \nProceeding. \n[Output Generation]\ntool_search(query=\"weather - tool\")\nDone. \n(Note: I''ll format it correctly as a tool call)\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"query\": - \"weather tool\"}","call_id":"chatcmpl-tool-a8fc33ab402e4ff0","name":"tool_search","type":"function_call","id":"fc_857408fee70af6a5","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":348,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":415,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":763},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":163,"type":"response.completed"} + - 'data: {"content_index":0,"delta":".\nParameters","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} ' - ' ' - status_code: 200 -- filename: t2 - request: - body: - input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. - role: user - type: message - - content: - - text: "The user wants me to call `tool_search` exactly once to find a weather\ - \ tool.\nI should not call `get_weather` yet.\nThe description for `tool_search`\ - \ says: \"Search the client tool catalog. Available catalog entry: get_weather\ - \ — Get the current weather for a city.\"\nI need to provide a query for\ - \ the tool search.\nQuery: \"find a weather tool\"\nI will call `tool_search`\ - \ with this query.\nWait, the prompt says \"First call tool_search exactly\ - \ once to find a weather tool. Do not call get_weather yet.\"\nSo I just\ - \ call `tool_search`.\nLet's do it.\nQuery: \"weather tool\"\nParameters:\ - \ `{\"query\": \"weather tool\"}`\nDone. \nChecking strictness: `allowed_callers`\ - \ is null, `defer_loading` is null. Only `tool_search` is available.\n\ - Proceed. \nOutput matches the format.\nI will generate the tool call.\ - \ \nNote: I should just output the tool call.\n```json\n{\n \"name\"\ - : \"tool_search\",\n \"parameters\": {\n \"query\": \"weather tool\"\ - \n }\n}\n```\nActually, the prompt says \"First call tool_search exactly\ - \ once to find a weather tool.\" I'll just make the call.\nDone. \nWait,\ - \ let's verify the schema: `tool_search` takes `query` (string).\nAll\ - \ good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement\ - \ during thought)\nShould I be more specific in the query? \"find a weather\ - \ tool\" is fine.\nI will generate the call. \nDone. \nProceeding. \n\ - [Output Generation]\ntool_search(query=\"weather tool\")\nDone. \n(Note:\ - \ I'll format it correctly as a tool call)\n" - type: reasoning_text - encrypted_content: null - id: rs_9327d10a4a99e129 - status: null - summary: [] - type: reasoning - - arguments: '{"query": "weather tool"}' - call_id: chatcmpl-tool-a8fc33ab402e4ff0 - caller: null - id: fc_857408fee70af6a5 - name: tool_search - namespace: null - status: completed - type: function_call - - call_id: chatcmpl-tool-a8fc33ab402e4ff0 - output: '{"tools":[{"defer_loading":true,"description":"Get the current weather - for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' - type: function_call_output - - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. - role: user - type: message - max_output_tokens: 4096 - model: Qwen/Qwen3.6-35B-A3B-FP8 - parallel_tool_calls: false - store: false - stream: true - tool_choice: auto - tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' - name: tool_search - parameters: - additionalProperties: false - properties: - query: - description: A concise description of the needed capability. - type: string - required: - - query - type: object - strict: true - type: function - - description: Get the current weather for a city. - name: get_weather - parameters: - additionalProperties: false - properties: - city: - type: string - required: - - city - type: object - strict: true - type: function - 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: - - 'event: response.created + - 'event: response.reasoning_text.delta ' - - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + - 'data: {"content_index":0,"delta":": `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.in_progress + - 'event: response.reasoning_text.delta ' - - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + - 'data: {"content_index":0,"delta":"city","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"id":"82d95d7ea20b54de","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + - 'data: {"content_index":0,"delta":": \"Paris","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_part.added + - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + - 'data: {"content_index":0,"delta":"\"`\nFunction","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} ' - ' @@ -1741,7 +1793,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"The","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": `get","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} ' - ' @@ -1750,7 +1802,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" user wants me","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_weather`\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} ' - ' @@ -1759,7 +1811,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to call the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Output","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} ' - ' @@ -1768,7 +1820,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `get_weather","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" schema","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} ' - ' @@ -1777,7 +1829,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` tool exactly","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" matches","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} ' - ' @@ -1786,7 +1838,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" once with the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" requirements","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} ' - ' @@ -1795,7 +1847,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" city","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\nProceed","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} ' - ' @@ -1804,7 +1856,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \"Paris\".","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"ing","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} ' - ' @@ -1813,7 +1865,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nI have","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":". \nWait","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} ' - ' @@ -1822,7 +1874,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" already called","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":", I","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} ' - ' @@ -1831,7 +1883,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool_search","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" should just call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} ' - ' @@ -1840,7 +1892,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` as","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the function","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} ' - ' @@ -1849,7 +1901,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" requested in","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" directly as","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} ' - ' @@ -1858,7 +1910,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the previous turn","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" requested.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} ' - ' @@ -1867,7 +1919,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\nThe","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} ' - ' @@ -1876,7 +1928,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" current request explicitly","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_weather(city","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} ' - ' @@ -1885,7 +1937,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tells","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"=\"Paris\")","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} ' - ' @@ -1894,7 +1946,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" me to use","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"`\nDone","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} ' - ' @@ -1903,7 +1955,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":". \nLet","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} ' - ' @@ -1912,7 +1964,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"''s format","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} ' - ' @@ -1921,7 +1973,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the tool","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} ' - ' @@ -1930,7 +1982,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"get_weather`","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" call.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} ' - ' @@ -1939,7 +1991,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" with parameters","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} ' - ' @@ -1948,7 +2000,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `{\"city","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} ' - ' @@ -1957,7 +2009,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\":\"","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":": default","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} ' - ' @@ -1966,7 +2018,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"Paris\"}`","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_api:get","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} ' - ' @@ -1975,7 +2027,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" and","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_weather{\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} ' - ' @@ -1984,7 +2036,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" not to call","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"city\": \"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} ' - ' @@ -1993,7 +2045,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" `tool_search","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Paris\"}`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} ' - ' @@ -2002,7 +2054,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"` again.","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\nI","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} ' - ' @@ -2011,7 +2063,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nI will","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" will generate the","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} ' - ' @@ -2020,7 +2072,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" construct","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" tool call.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} ' - ' @@ -2029,7 +2081,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the function","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" \nWait,","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} ' - ' @@ -2038,7 +2090,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" call for `","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" checking","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} ' - ' @@ -2047,7 +2099,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"get_weather`.","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the prompt:","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} ' - ' @@ -2056,7 +2108,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nParameters","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" \"Now","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} ' - ' @@ -2065,7 +2117,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": city =","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" call get_weather","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} ' - ' @@ -2074,7 +2126,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \"Paris\".","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" exactly once with","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} ' - ' @@ -2083,7 +2135,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\nFunction","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" {\"city\":\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} ' - ' @@ -2092,7 +2144,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" name","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Paris\"}.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} ' - ' @@ -2101,7 +2153,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": get_weather","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" Do not call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} ' - ' @@ -2110,7 +2162,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\nCall","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" any other tool","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} ' - ' @@ -2119,7 +2171,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\"\nReady","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} ' - ' @@ -2128,221 +2180,2292 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tool.\n","item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":". \nProceed","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_text.done + - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"sequence_number":48,"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already called `tool_search` as requested in the previous turn.\nThe current - request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` - and not to call `tool_search` again.\nI will construct the function call for - `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall - the tool.\n","type":"response.reasoning_text.done"} + - 'data: {"content_index":0,"delta":"ing. \n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.reasoning_part.done + - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"item_id":"82d95d7ea20b54de","output_index":0,"part":{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already called `tool_search` as requested in the previous turn.\nThe current - request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` - and not to call `tool_search` again.\nI will construct the function call for - `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall - the tool.\n","type":"reasoning_text"},"sequence_number":49,"type":"response.reasoning_part.done"} + - 'data: {"content_index":0,"delta":"[Self","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"id":"82d95d7ea20b54de","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already called `tool_search` as requested in the previous turn.\nThe current - request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` - and not to call `tool_search` again.\nI will construct the function call for - `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall - the tool.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":50,"type":"response.output_item.done"} + - 'data: {"content_index":0,"delta":"-Correction","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"arguments":"","call_id":"call_bbdce13c58516370","name":"get_weather","type":"function_call","id":"91decd0f7ded9c1c","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":51,"type":"response.output_item.added"} + - 'data: {"content_index":0,"delta":"/Verification]","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"{\"city\": \"","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":52,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":"\n-","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"Paris","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":53,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":" Tool:","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta ' - - 'data: {"delta":"\"}","item_id":"91decd0f7ded9c1c","output_index":1,"sequence_number":54,"type":"response.function_call_arguments.delta"} + - 'data: {"content_index":0,"delta":" get","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.function_call_arguments.done + - 'event: response.reasoning_text.delta ' - - 'data: {"arguments":"{\"city\": \"Paris\"}","item_id":"91decd0f7ded9c1c","name":"get_weather","output_index":1,"sequence_number":55,"type":"response.function_call_arguments.done"} + - 'data: {"content_index":0,"delta":"_weather\n-","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta ' - - 'data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_bbdce13c58516370","name":"get_weather","type":"function_call","id":"91decd0f7ded9c1c","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":56,"type":"response.output_item.done"} + - 'data: {"content_index":0,"delta":" Param","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} ' - ' ' - - 'event: response.completed + - 'event: response.reasoning_text.delta ' - - 'data: {"response":{"id":"resp_b2bb35b4afbde3cb","created_at":1787143229,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_8249dd1d18d7cab2","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already called `tool_search` as requested in the previous turn.\nThe current - request explicitly tells me to use the tool `get_weather` with parameters `{\"city\":\"Paris\"}` - and not to call `tool_search` again.\nI will construct the function call for - `get_weather`.\nParameters: city = \"Paris\".\nFunction name: get_weather.\nCall - the tool.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"city\": - \"Paris\"}","call_id":"chatcmpl-tool-8289918f90de051c","name":"get_weather","type":"function_call","id":"fc_bb8169afc12efa5f","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":562,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":131,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":693},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":57,"type":"response.completed"} + - 'data: {"content_index":0,"delta":": city =","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} ' - ' ' - status_code: 200 -- filename: t3 - request: + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"Paris\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n- Strict","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" schema","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": matches","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n- Only","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" one call.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nMatches","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" all","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" constraints.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Output matches the","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" expected","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" format.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Done. \n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Proceeding.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \n[Output","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Generation] ->","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Tool call.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \n`print","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"(tool_call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".get","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_weather(city=\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris\"))`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" -> Wait","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":", the format","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is `","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" or similar","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" depending","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" on the system","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":140,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" I","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":141,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" will use","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":142,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the standard tool","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":143,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call format.","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":144,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nActually,","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":145,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" I","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":146,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"''ll","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":147,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" just generate","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":148,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the tool call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":149,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" block.\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":150,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":151,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":153,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":156,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":157,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":158,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":159,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Wait, the","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":160,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" prompt says \"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":161,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Now","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":162,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" call get_weather","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":163,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exactly once with","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":164,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" {\"city\":\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":165,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris\"}","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":166,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\"\nI","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":167,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" will generate","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":168,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the call","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":169,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nDone","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":170,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nProceed","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":171,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"s","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":172,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \n[","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":173,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Final","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":174,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" Check]\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":175,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Function","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":176,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": get_weather","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":177,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nArgs","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":178,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": {\"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":179,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"city\": \"","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":180,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris\"}\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":181,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Matches","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":182,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":183,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nOutput","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":184,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"ting","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":185,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \n[","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":186,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Done]\n","item_id":"be7af246b8d8923b","output_index":0,"sequence_number":187,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"content_index":0,"item_id":"be7af246b8d8923b","output_index":0,"sequence_number":188,"text":"The + user wants me to call `get_weather` with the city \"Paris\".\nI need to check + the available tools to make sure `get_weather` is available.\nFrom the previous + tool_search result:\n- `get_weather` is available with description \"Get the + current weather for a city\", parameters: city (string).\n- `travel` is a namespace + containing `get_timezone`.\n\nThe user''s request is straightforward: call `get_weather` + with `{\"city\": \"Paris\"}`.\nI will proceed with the function call.\nNo other + tools should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\nOutput + schema matches requirements.\nProceeding. \nWait, I should just call the function + directly as requested.\n`get_weather(city=\"Paris\")`\nDone. \nLet''s format + the tool call.\n`call: default_api:get_weather{\"city\": \"Paris\"}`\nI will + generate the tool call. \nWait, checking the prompt: \"Now call get_weather + exactly once with {\"city\":\"Paris\"}. Do not call any other tool.\"\nReady. + \nProceeding. \n[Self-Correction/Verification]\n- Tool: get_weather\n- Param: + city = \"Paris\"\n- Strict schema: matches.\n- Only one call.\nMatches all constraints.\nOutput + matches the expected format.\nDone. \nProceeding. \n[Output Generation] -> Tool + call. \n`print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is + `` or similar depending on the system. I will use the standard + tool call format.\nActually, I''ll just generate the tool call block.\n`Paris`\nWait, the prompt says \"Now call + get_weather exactly once with {\"city\":\"Paris\"}.\"\nI will generate the call.\nDone. + \nProceeds. \n[Final Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\nOutputting. + \n[Done]\n","type":"response.reasoning_text.done"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"content_index":0,"item_id":"be7af246b8d8923b","output_index":0,"part":{"text":"The + user wants me to call `get_weather` with the city \"Paris\".\nI need to check + the available tools to make sure `get_weather` is available.\nFrom the previous + tool_search result:\n- `get_weather` is available with description \"Get the + current weather for a city\", parameters: city (string).\n- `travel` is a namespace + containing `get_timezone`.\n\nThe user''s request is straightforward: call `get_weather` + with `{\"city\": \"Paris\"}`.\nI will proceed with the function call.\nNo other + tools should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\nOutput + schema matches requirements.\nProceeding. \nWait, I should just call the function + directly as requested.\n`get_weather(city=\"Paris\")`\nDone. \nLet''s format + the tool call.\n`call: default_api:get_weather{\"city\": \"Paris\"}`\nI will + generate the tool call. \nWait, checking the prompt: \"Now call get_weather + exactly once with {\"city\":\"Paris\"}. Do not call any other tool.\"\nReady. + \nProceeding. \n[Self-Correction/Verification]\n- Tool: get_weather\n- Param: + city = \"Paris\"\n- Strict schema: matches.\n- Only one call.\nMatches all constraints.\nOutput + matches the expected format.\nDone. \nProceeding. \n[Output Generation] -> Tool + call. \n`print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is + `` or similar depending on the system. I will use the standard + tool call format.\nActually, I''ll just generate the tool call block.\n`Paris`\nWait, the prompt says \"Now call + get_weather exactly once with {\"city\":\"Paris\"}.\"\nI will generate the call.\nDone. + \nProceeds. \n[Final Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\nOutputting. + \n[Done]\n","type":"reasoning_text"},"sequence_number":189,"type":"response.reasoning_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"be7af246b8d8923b","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `get_weather` with the city \"Paris\".\nI need to check + the available tools to make sure `get_weather` is available.\nFrom the previous + tool_search result:\n- `get_weather` is available with description \"Get the + current weather for a city\", parameters: city (string).\n- `travel` is a namespace + containing `get_timezone`.\n\nThe user''s request is straightforward: call `get_weather` + with `{\"city\": \"Paris\"}`.\nI will proceed with the function call.\nNo other + tools should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\nOutput + schema matches requirements.\nProceeding. \nWait, I should just call the function + directly as requested.\n`get_weather(city=\"Paris\")`\nDone. \nLet''s format + the tool call.\n`call: default_api:get_weather{\"city\": \"Paris\"}`\nI will + generate the tool call. \nWait, checking the prompt: \"Now call get_weather + exactly once with {\"city\":\"Paris\"}. Do not call any other tool.\"\nReady. + \nProceeding. \n[Self-Correction/Verification]\n- Tool: get_weather\n- Param: + city = \"Paris\"\n- Strict schema: matches.\n- Only one call.\nMatches all constraints.\nOutput + matches the expected format.\nDone. \nProceeding. \n[Output Generation] -> Tool + call. \n`print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is + `` or similar depending on the system. I will use the standard + tool call format.\nActually, I''ll just generate the tool call block.\n`Paris`\nWait, the prompt says \"Now call + get_weather exactly once with {\"city\":\"Paris\"}.\"\nI will generate the call.\nDone. + \nProceeds. \n[Final Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\nOutputting. + \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":190,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"arguments":"","call_id":"call_85f00c5ab2d7b148","name":"get_weather","type":"function_call","id":"9603826cd92496be","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":191,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"{\"city\": \"","item_id":"9603826cd92496be","output_index":1,"sequence_number":192,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"Paris","item_id":"9603826cd92496be","output_index":1,"sequence_number":193,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"\"}","item_id":"9603826cd92496be","output_index":1,"sequence_number":194,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"arguments":"{\"city\": \"Paris\"}","item_id":"9603826cd92496be","name":"get_weather","output_index":1,"sequence_number":195,"type":"response.function_call_arguments.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_85f00c5ab2d7b148","name":"get_weather","type":"function_call","id":"9603826cd92496be","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":196,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"response":{"id":"resp_a1f440f71446236e","created_at":1787714233,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_bc56577bb32c7d4d","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call `get_weather` with the city \"Paris\".\nI need to check + the available tools to make sure `get_weather` is available.\nFrom the previous + tool_search result:\n- `get_weather` is available with description \"Get the + current weather for a city\", parameters: city (string).\n- `travel` is a namespace + containing `get_timezone`.\n\nThe user''s request is straightforward: call `get_weather` + with `{\"city\": \"Paris\"}`.\nI will proceed with the function call.\nNo other + tools should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\nOutput + schema matches requirements.\nProceeding. \nWait, I should just call the function + directly as requested.\n`get_weather(city=\"Paris\")`\nDone. \nLet''s format + the tool call.\n`call: default_api:get_weather{\"city\": \"Paris\"}`\nI will + generate the tool call. \nWait, checking the prompt: \"Now call get_weather + exactly once with {\"city\":\"Paris\"}. Do not call any other tool.\"\nReady. + \nProceeding. \n[Self-Correction/Verification]\n- Tool: get_weather\n- Param: + city = \"Paris\"\n- Strict schema: matches.\n- Only one call.\nMatches all constraints.\nOutput + matches the expected format.\nDone. \nProceeding. \n[Output Generation] -> Tool + call. \n`print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is + `` or similar depending on the system. I will use the standard + tool call format.\nActually, I''ll just generate the tool call block.\n`Paris`\nWait, the prompt says \"Now call + get_weather exactly once with {\"city\":\"Paris\"}.\"\nI will generate the call.\nDone. + \nProceeds. \n[Final Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\nOutputting. + \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"city\": + \"Paris\"}","call_id":"chatcmpl-tool-a2ab60b3fa2de286","name":"get_weather","type":"function_call","id":"fc_af9a4cea3529b0ea","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":776,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":467,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":1243},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":197,"type":"response.completed"} + + ' + - ' + + ' + status_code: 200 +- filename: t3 + request: + body: + input: + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. + role: user + type: message + - content: + - text: 'The user wants me to call `tool_search` exactly once. + + The query needs to find two specific capabilities: + + 1. A current-weather function. + + 2. A travel time-zone function. + + + I will formulate the query to include both requests. + + Query: "current weather function and travel time-zone function" + + + Then I will call `tool_search`. + + I must not call any other tool. + + The available tools in the description are: get_weather, get_exchange_rate, + search_hotels, travel. + + "get_weather" fits "current-weather function". + + "travel" fits "travel time-zone function" (or similar travel capability). + + + I will construct the tool call now. + + Tool: tool_search + + Parameters: query="current-weather function and travel time-zone function" + + + Let''s double check the requirements. + + - Call tool_search exactly once. + + - Find a current-weather function and a travel time-zone function. + + - Do not call any other tool yet. + + + The query string should be descriptive enough. + + Query: "current weather function and travel time-zone function" looks + good. + + I will proceed with the tool call. + + ' + type: reasoning_text + encrypted_content: null + id: rs_a7bd7fe3378ba674 + status: null + summary: [] + type: reasoning + - arguments: '{"query": "current-weather function and travel time-zone function"}' + call_id: chatcmpl-tool-832a4075db59f392 + caller: null + id: fc_8543ff56a05e2fc7 + name: tool_search + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-832a4075db59f392 + output: '{"tools":[{"defer_loading":true,"description":"Get the current weather + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' + type: function_call_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + any other tool. + role: user + type: message + - content: + - text: "The user wants me to call `get_weather` with the city \"Paris\".\n\ + I need to check the available tools to make sure `get_weather` is available.\n\ + From the previous tool_search result:\n- `get_weather` is available with\ + \ description \"Get the current weather for a city\", parameters: city\ + \ (string).\n- `travel` is a namespace containing `get_timezone`.\n\n\ + The user's request is straightforward: call `get_weather` with `{\"city\"\ + : \"Paris\"}`.\nI will proceed with the function call.\nNo other tools\ + \ should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\n\ + Output schema matches requirements.\nProceeding. \nWait, I should just\ + \ call the function directly as requested.\n`get_weather(city=\"Paris\"\ + )`\nDone. \nLet's format the tool call.\n`call: default_api:get_weather{\"\ + city\": \"Paris\"}`\nI will generate the tool call. \nWait, checking the\ + \ prompt: \"Now call get_weather exactly once with {\"city\":\"Paris\"\ + }. Do not call any other tool.\"\nReady. \nProceeding. \n[Self-Correction/Verification]\n\ + - Tool: get_weather\n- Param: city = \"Paris\"\n- Strict schema: matches.\n\ + - Only one call.\nMatches all constraints.\nOutput matches the expected\ + \ format.\nDone. \nProceeding. \n[Output Generation] -> Tool call. \n\ + `print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is\ + \ `` or similar depending on the system. I will use the\ + \ standard tool call format.\nActually, I'll just generate the tool call\ + \ block.\n`Paris`\n\ + Wait, the prompt says \"Now call get_weather exactly once with {\"city\"\ + :\"Paris\"}.\"\nI will generate the call.\nDone. \nProceeds. \n[Final\ + \ Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\n\ + Outputting. \n[Done]\n" + type: reasoning_text + encrypted_content: null + id: rs_bc56577bb32c7d4d + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-a2ab60b3fa2de286 + caller: null + id: fc_af9a4cea3529b0ea + name: get_weather + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-a2ab60b3fa2de286 + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: true + tool_choice: + name: agentic_ns__travel__get_timezone + type: function + tools: + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' + name: tool_search + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capabilities. + type: string + required: + - query + type: object + strict: true + type: function + - description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + 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: + - 'event: response.created + + ' + - 'data: {"response":{"id":"resp_b66e8a819de0a8d5","created_at":1787714235,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"agentic_ns__travel__get_timezone","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"response":{"id":"resp_b66e8a819de0a8d5","created_at":1787714235,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"agentic_ns__travel__get_timezone","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"id":"b015db17c93a5a58","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"content_index":0,"item_id":"b015db17c93a5a58","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"The","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" user wants me","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to call the","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `get_timezone","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` tool","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" from the `","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"travel` namespace","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" with","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" parameter `{\"","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"city\":","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" \"Paris\"","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"}`.\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"I have","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" already called `","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"tool_search`","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" and `","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"get_weather`","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" as requested","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" in","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" previous turns","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\nNow","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" I just","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" need to call","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `ag","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"entic_ns__","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"travel__get","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_timezone` (","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"since","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the tool search","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" showed it","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" as","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" part","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" of the travel","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" namespace,","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" but","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the actual","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" tool","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" name in the","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" schema is `","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"agentic_ns","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"__travel__","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"get_timezone`).","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Let''s check","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the tool schema","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":":\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`agentic","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_ns__travel","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"__get_timezone","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Parameters: `","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"city","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"` (required","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":",","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" string)\n\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"I will call","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" this","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" function","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" with","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `city","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"=\"Paris\"","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"`.\nNo","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" other tools should","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" be called.","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nDone","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":". \nProceed","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"ing. \n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Wait, let","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"''s verify the","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exact tool name","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" from the schema","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n`ag","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"entic_ns__","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"travel__get","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_timezone`\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Parameters","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":": `{\"","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"city\": \"","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Paris\"}`","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nMatches.","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nCalling","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"...","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n","item_id":"b015db17c93a5a58","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"content_index":0,"item_id":"b015db17c93a5a58","output_index":0,"sequence_number":89,"text":"The + user wants me to call the `get_timezone` tool from the `travel` namespace with + the parameter `{\"city\": \"Paris\"}`.\nI have already called `tool_search` + and `get_weather` as requested in previous turns.\nNow I just need to call `agentic_ns__travel__get_timezone` + (since the tool search showed it as part of the travel namespace, but the actual + tool name in the schema is `agentic_ns__travel__get_timezone`).\n\nLet''s check + the tool schema:\n`agentic_ns__travel__get_timezone`\nParameters: `city` (required, + string)\n\nI will call this function with `city=\"Paris\"`.\nNo other tools + should be called.\nDone. \nProceeding. \nWait, let''s verify the exact tool + name from the schema.\n`agentic_ns__travel__get_timezone`\nParameters: `{\"city\": + \"Paris\"}`\nMatches.\nCalling...\n","type":"response.reasoning_text.done"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"content_index":0,"item_id":"b015db17c93a5a58","output_index":0,"part":{"text":"The + user wants me to call the `get_timezone` tool from the `travel` namespace with + the parameter `{\"city\": \"Paris\"}`.\nI have already called `tool_search` + and `get_weather` as requested in previous turns.\nNow I just need to call `agentic_ns__travel__get_timezone` + (since the tool search showed it as part of the travel namespace, but the actual + tool name in the schema is `agentic_ns__travel__get_timezone`).\n\nLet''s check + the tool schema:\n`agentic_ns__travel__get_timezone`\nParameters: `city` (required, + string)\n\nI will call this function with `city=\"Paris\"`.\nNo other tools + should be called.\nDone. \nProceeding. \nWait, let''s verify the exact tool + name from the schema.\n`agentic_ns__travel__get_timezone`\nParameters: `{\"city\": + \"Paris\"}`\nMatches.\nCalling...\n","type":"reasoning_text"},"sequence_number":90,"type":"response.reasoning_part.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"id":"b015db17c93a5a58","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call the `get_timezone` tool from the `travel` namespace with + the parameter `{\"city\": \"Paris\"}`.\nI have already called `tool_search` + and `get_weather` as requested in previous turns.\nNow I just need to call `agentic_ns__travel__get_timezone` + (since the tool search showed it as part of the travel namespace, but the actual + tool name in the schema is `agentic_ns__travel__get_timezone`).\n\nLet''s check + the tool schema:\n`agentic_ns__travel__get_timezone`\nParameters: `city` (required, + string)\n\nI will call this function with `city=\"Paris\"`.\nNo other tools + should be called.\nDone. \nProceeding. \nWait, let''s verify the exact tool + name from the schema.\n`agentic_ns__travel__get_timezone`\nParameters: `{\"city\": + \"Paris\"}`\nMatches.\nCalling...\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":91,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"item":{"arguments":"","call_id":"call_b805fdef2ae515cb","name":"agentic_ns__travel__get_timezone","type":"function_call","id":"9edb1495cd70884f","caller":null,"namespace":null,"status":"in_progress"},"output_index":1,"sequence_number":92,"type":"response.output_item.added"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"{\"city\": \"Paris","item_id":"9edb1495cd70884f","output_index":1,"sequence_number":93,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"delta":"\"}","item_id":"9edb1495cd70884f","output_index":1,"sequence_number":94,"type":"response.function_call_arguments.delta"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"arguments":"{\"city\": \"Paris\"}","item_id":"9edb1495cd70884f","name":"agentic_ns__travel__get_timezone","output_index":1,"sequence_number":95,"type":"response.function_call_arguments.done"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_b805fdef2ae515cb","name":"agentic_ns__travel__get_timezone","type":"function_call","id":"9edb1495cd70884f","caller":null,"namespace":null,"status":"completed"},"output_index":1,"sequence_number":96,"type":"response.output_item.done"} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"response":{"id":"resp_b66e8a819de0a8d5","created_at":1787714235,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_822c3e0e98dcc5ed","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to call the `get_timezone` tool from the `travel` namespace with + the parameter `{\"city\": \"Paris\"}`.\nI have already called `tool_search` + and `get_weather` as requested in previous turns.\nNow I just need to call `agentic_ns__travel__get_timezone` + (since the tool search showed it as part of the travel namespace, but the actual + tool name in the schema is `agentic_ns__travel__get_timezone`).\n\nLet''s check + the tool schema:\n`agentic_ns__travel__get_timezone`\nParameters: `city` (required, + string)\n\nI will call this function with `city=\"Paris\"`.\nNo other tools + should be called.\nDone. \nProceeding. \nWait, let''s verify the exact tool + name from the schema.\n`agentic_ns__travel__get_timezone`\nParameters: `{\"city\": + \"Paris\"}`\nMatches.\nCalling...\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"arguments":"{\"city\": + \"Paris\"}","call_id":"chatcmpl-tool-a66bd2c3ec56cbe7","name":"agentic_ns__travel__get_timezone","type":"function_call","id":"fc_a76e1ed6c8aa45cc","caller":null,"namespace":null,"status":"completed"}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":{"name":"agentic_ns__travel__get_timezone","type":"function"},"tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":860,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":239,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":1099},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":97,"type":"response.completed"} + + ' + - ' + + ' + status_code: 200 +- filename: t4 + request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message - content: - - text: "The user wants me to call `tool_search` exactly once to find a weather\ - \ tool.\nI should not call `get_weather` yet.\nThe description for `tool_search`\ - \ says: \"Search the client tool catalog. Available catalog entry: get_weather\ - \ — Get the current weather for a city.\"\nI need to provide a query for\ - \ the tool search.\nQuery: \"find a weather tool\"\nI will call `tool_search`\ - \ with this query.\nWait, the prompt says \"First call tool_search exactly\ - \ once to find a weather tool. Do not call get_weather yet.\"\nSo I just\ - \ call `tool_search`.\nLet's do it.\nQuery: \"weather tool\"\nParameters:\ - \ `{\"query\": \"weather tool\"}`\nDone. \nChecking strictness: `allowed_callers`\ - \ is null, `defer_loading` is null. Only `tool_search` is available.\n\ - Proceed. \nOutput matches the format.\nI will generate the tool call.\ - \ \nNote: I should just output the tool call.\n```json\n{\n \"name\"\ - : \"tool_search\",\n \"parameters\": {\n \"query\": \"weather tool\"\ - \n }\n}\n```\nActually, the prompt says \"First call tool_search exactly\ - \ once to find a weather tool.\" I'll just make the call.\nDone. \nWait,\ - \ let's verify the schema: `tool_search` takes `query` (string).\nAll\ - \ good.\nProceed. \nOutput matches. \nDone. \n(Self-Correction/Refinement\ - \ during thought)\nShould I be more specific in the query? \"find a weather\ - \ tool\" is fine.\nI will generate the call. \nDone. \nProceeding. \n\ - [Output Generation]\ntool_search(query=\"weather tool\")\nDone. \n(Note:\ - \ I'll format it correctly as a tool call)\n" + - text: 'The user wants me to call `tool_search` exactly once. + + The query needs to find two specific capabilities: + + 1. A current-weather function. + + 2. A travel time-zone function. + + + I will formulate the query to include both requests. + + Query: "current weather function and travel time-zone function" + + + Then I will call `tool_search`. + + I must not call any other tool. + + The available tools in the description are: get_weather, get_exchange_rate, + search_hotels, travel. + + "get_weather" fits "current-weather function". + + "travel" fits "travel time-zone function" (or similar travel capability). + + + I will construct the tool call now. + + Tool: tool_search + + Parameters: query="current-weather function and travel time-zone function" + + + Let''s double check the requirements. + + - Call tool_search exactly once. + + - Find a current-weather function and a travel time-zone function. + + - Do not call any other tool yet. + + + The query string should be descriptive enough. + + Query: "current weather function and travel time-zone function" looks + good. + + I will proceed with the tool call. + + ' type: reasoning_text encrypted_content: null - id: rs_9327d10a4a99e129 + id: rs_a7bd7fe3378ba674 status: null summary: [] type: reasoning - - arguments: '{"query": "weather tool"}' - call_id: chatcmpl-tool-a8fc33ab402e4ff0 + - arguments: '{"query": "current-weather function and travel time-zone function"}' + call_id: chatcmpl-tool-832a4075db59f392 caller: null - id: fc_857408fee70af6a5 + id: fc_8543ff56a05e2fc7 name: tool_search namespace: null status: completed type: function_call - - call_id: chatcmpl-tool-a8fc33ab402e4ff0 + - call_id: chatcmpl-tool-832a4075db59f392 output: '{"tools":[{"defer_loading":true,"description":"Get the current weather - for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}]}' + for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}]}' type: function_call_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message - content: - - text: 'The user wants me to call the `get_weather` tool exactly once with - the city "Paris". - - I have already called `tool_search` as requested in the previous turn. - - The current request explicitly tells me to use the tool `get_weather` - with parameters `{"city":"Paris"}` and not to call `tool_search` again. - - I will construct the function call for `get_weather`. - - Parameters: city = "Paris". - - Function name: get_weather. - - Call the tool. - - ' + - text: "The user wants me to call `get_weather` with the city \"Paris\".\n\ + I need to check the available tools to make sure `get_weather` is available.\n\ + From the previous tool_search result:\n- `get_weather` is available with\ + \ description \"Get the current weather for a city\", parameters: city\ + \ (string).\n- `travel` is a namespace containing `get_timezone`.\n\n\ + The user's request is straightforward: call `get_weather` with `{\"city\"\ + : \"Paris\"}`.\nI will proceed with the function call.\nNo other tools\ + \ should be called.\nParameters: `city: \"Paris\"`\nFunction: `get_weather`\n\ + Output schema matches requirements.\nProceeding. \nWait, I should just\ + \ call the function directly as requested.\n`get_weather(city=\"Paris\"\ + )`\nDone. \nLet's format the tool call.\n`call: default_api:get_weather{\"\ + city\": \"Paris\"}`\nI will generate the tool call. \nWait, checking the\ + \ prompt: \"Now call get_weather exactly once with {\"city\":\"Paris\"\ + }. Do not call any other tool.\"\nReady. \nProceeding. \n[Self-Correction/Verification]\n\ + - Tool: get_weather\n- Param: city = \"Paris\"\n- Strict schema: matches.\n\ + - Only one call.\nMatches all constraints.\nOutput matches the expected\ + \ format.\nDone. \nProceeding. \n[Output Generation] -> Tool call. \n\ + `print(tool_call.get_weather(city=\"Paris\"))` -> Wait, the format is\ + \ `` or similar depending on the system. I will use the\ + \ standard tool call format.\nActually, I'll just generate the tool call\ + \ block.\n`Paris`\n\ + Wait, the prompt says \"Now call get_weather exactly once with {\"city\"\ + :\"Paris\"}.\"\nI will generate the call.\nDone. \nProceeds. \n[Final\ + \ Check]\nFunction: get_weather\nArgs: {\"city\": \"Paris\"}\nMatches.\n\ + Outputting. \n[Done]\n" type: reasoning_text encrypted_content: null - id: rs_8249dd1d18d7cab2 + id: rs_bc56577bb32c7d4d status: null summary: [] type: reasoning - arguments: '{"city": "Paris"}' - call_id: chatcmpl-tool-8289918f90de051c + call_id: chatcmpl-tool-a2ab60b3fa2de286 caller: null - id: fc_bb8169afc12efa5f + id: fc_af9a4cea3529b0ea name: get_weather namespace: null status: completed type: function_call - - call_id: chatcmpl-tool-8289918f90de051c + - call_id: chatcmpl-tool-a2ab60b3fa2de286 output: '{"city":"Paris","condition":"clear","temperature_c":21}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + - content: + - text: "The user wants me to call the `get_timezone` tool from the `travel`\ + \ namespace with the parameter `{\"city\": \"Paris\"}`.\nI have already\ + \ called `tool_search` and `get_weather` as requested in previous turns.\n\ + Now I just need to call `agentic_ns__travel__get_timezone` (since the\ + \ tool search showed it as part of the travel namespace, but the actual\ + \ tool name in the schema is `agentic_ns__travel__get_timezone`).\n\n\ + Let's check the tool schema:\n`agentic_ns__travel__get_timezone`\nParameters:\ + \ `city` (required, string)\n\nI will call this function with `city=\"\ + Paris\"`.\nNo other tools should be called.\nDone. \nProceeding. \nWait,\ + \ let's verify the exact tool name from the schema.\n`agentic_ns__travel__get_timezone`\n\ + Parameters: `{\"city\": \"Paris\"}`\nMatches.\nCalling...\n" + type: reasoning_text + encrypted_content: null + id: rs_822c3e0e98dcc5ed + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-a66bd2c3ec56cbe7 + caller: null + id: fc_a76e1ed6c8aa45cc + name: agentic_ns__travel__get_timezone + namespace: null + status: completed + type: function_call + - call_id: chatcmpl-tool-a66bd2c3ec56cbe7 + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. role: user type: message max_output_tokens: 4096 @@ -2350,23 +4473,25 @@ turns: parallel_tool_calls: false store: false stream: true - tool_choice: auto + tool_choice: none tools: - - description: 'Search the client tool catalog. Available catalog entry: get_weather - — Get the current weather for a city.' + - description: 'Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_exchange_rate — Get the exchange + rate between two currencies; search_hotels — Search for hotels in a city; + travel — Travel location tools.' name: tool_search parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object strict: true type: function - - description: Get the current weather for a city. + - description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -2378,6 +4503,18 @@ turns: type: object strict: true type: function + - description: Get the IANA time zone for a city + name: agentic_ns__travel__get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function headers: accept: '*/*' content-type: application/json @@ -2392,11 +4529,13 @@ turns: - 'event: response.created ' - - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} + - 'data: {"response":{"id":"resp_a9fb3e0ad5ebdc49","created_at":1787714237,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"none","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":0,"type":"response.created"} ' - ' @@ -2405,11 +4544,13 @@ turns: - 'event: response.in_progress ' - - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} + - 'data: {"response":{"id":"resp_a9fb3e0ad5ebdc49","created_at":1787714237,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"none","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","text":null,"top_logprobs":null,"truncation":"disabled","usage":null,"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":1,"type":"response.in_progress"} ' - ' @@ -2418,7 +4559,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"item":{"id":"905f67d33894f159","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + - 'data: {"item":{"id":"95607da65d299418","summary":[],"type":"reasoning","content":null,"encrypted_content":null,"status":"in_progress"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} ' - ' @@ -2427,7 +4568,7 @@ turns: - 'event: response.reasoning_part.added ' - - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + - 'data: {"content_index":0,"item_id":"95607da65d299418","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} ' - ' @@ -2436,7 +4577,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"The","item_id":"905f67d33894f159","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"The","item_id":"95607da65d299418","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} ' - ' @@ -2445,7 +4586,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" user wants me","item_id":"905f67d33894f159","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" user wants me","item_id":"95607da65d299418","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} ' - ' @@ -2454,7 +4595,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to use","item_id":"905f67d33894f159","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" to output","item_id":"95607da65d299418","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} ' - ' @@ -2463,7 +4604,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" the function output","item_id":"905f67d33894f159","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" a","item_id":"95607da65d299418","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} ' - ' @@ -2472,7 +4613,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" and reply with","item_id":"905f67d33894f159","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" specific string \"","item_id":"95607da65d299418","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} ' - ' @@ -2481,7 +4622,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" exactly \"PAR","item_id":"905f67d33894f159","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"PARIS_MIX","item_id":"95607da65d299418","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} ' - ' @@ -2490,7 +4631,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"IS_WEATHER","item_id":"905f67d33894f159","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"ED_TOOLS","item_id":"95607da65d299418","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} ' - ' @@ -2499,7 +4640,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_OK\".\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"_OK\" after","item_id":"95607da65d299418","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} ' - ' @@ -2508,7 +4649,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"No","item_id":"905f67d33894f159","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" using the results","item_id":"95607da65d299418","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} ' - ' @@ -2517,7 +4658,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" more","item_id":"905f67d33894f159","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" from","item_id":"95607da65d299418","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} ' - ' @@ -2526,7 +4667,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" tools should be","item_id":"905f67d33894f159","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" the previous tool","item_id":"95607da65d299418","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} ' - ' @@ -2535,7 +4676,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" called.\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" calls.\n","item_id":"95607da65d299418","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} ' - ' @@ -2544,7 +4685,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"The function output","item_id":"905f67d33894f159","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"I have called","item_id":"95607da65d299418","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} ' - ' @@ -2553,7 +4694,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" for","item_id":"905f67d33894f159","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" `get_weather","item_id":"95607da65d299418","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} ' - ' @@ -2562,7 +4703,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" Paris","item_id":"905f67d33894f159","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"` for Paris","item_id":"95607da65d299418","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} ' - ' @@ -2571,7 +4712,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" is","item_id":"905f67d33894f159","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":",","item_id":"95607da65d299418","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} ' - ' @@ -2580,7 +4721,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":": {\"city","item_id":"905f67d33894f159","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" which returned `","item_id":"95607da65d299418","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} ' - ' @@ -2589,7 +4730,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\":\"Paris\",\"","item_id":"905f67d33894f159","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"{\"city\":\"","item_id":"95607da65d299418","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} ' - ' @@ -2598,7 +4739,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"condition\":\"clear","item_id":"905f67d33894f159","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"Paris\",\"condition","item_id":"95607da65d299418","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} ' - ' @@ -2607,7 +4748,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\",\"temperature_c","item_id":"905f67d33894f159","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"\":\"clear\",\"","item_id":"95607da65d299418","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} ' - ' @@ -2616,7 +4757,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\":21","item_id":"905f67d33894f159","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"temperature_c\":","item_id":"95607da65d299418","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} ' - ' @@ -2625,7 +4766,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"}\nI","item_id":"905f67d33894f159","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"21}`","item_id":"95607da65d299418","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} ' - ' @@ -2634,7 +4775,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" need","item_id":"905f67d33894f159","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":".\nI","item_id":"95607da65d299418","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} ' - ' @@ -2643,7 +4784,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" to output exactly","item_id":"905f67d33894f159","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" have called `","item_id":"95607da65d299418","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} ' - ' @@ -2652,7 +4793,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":" \"PARIS","item_id":"905f67d33894f159","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"get_timezone`","item_id":"95607da65d299418","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} ' - ' @@ -2661,7 +4802,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"_WEATHER_OK","item_id":"905f67d33894f159","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":" for Paris (","item_id":"95607da65d299418","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} ' - ' @@ -2670,7 +4811,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":"\".\nDone","item_id":"905f67d33894f159","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"via `","item_id":"95607da65d299418","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} ' - ' @@ -2679,7 +4820,286 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"content_index":0,"delta":".\n","item_id":"905f67d33894f159","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + - 'data: {"content_index":0,"delta":"travel` namespace","item_id":"95607da65d299418","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"), which returned","item_id":"95607da65d299418","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" `{\"city","item_id":"95607da65d299418","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\":\"Paris\",\"","item_id":"95607da65d299418","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"iana_timezone\":\"","item_id":"95607da65d299418","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"Europe/Paris","item_id":"95607da65d299418","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\"}`.","item_id":"95607da65d299418","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\nThe user","item_id":"95607da65d299418","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" instructed","item_id":"95607da65d299418","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" to","item_id":"95607da65d299418","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" use","item_id":"95607da65d299418","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" both outputs","item_id":"95607da65d299418","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" and call no","item_id":"95607da65d299418","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" more tools,","item_id":"95607da65d299418","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" then reply with","item_id":"95607da65d299418","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" exactly \"PAR","item_id":"95607da65d299418","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"IS_MIXED","item_id":"95607da65d299418","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"_TOOLS_OK","item_id":"95607da65d299418","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"\".\n\nThe","item_id":"95607da65d299418","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" request","item_id":"95607da65d299418","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is straightforward","item_id":"95607da65d299418","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":":","item_id":"95607da65d299418","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" output","item_id":"95607da65d299418","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" the specific","item_id":"95607da65d299418","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" string. No","item_id":"95607da65d299418","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" further tool usage","item_id":"95607da65d299418","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" is permitted","item_id":"95607da65d299418","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\n","item_id":"95607da65d299418","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":"I","item_id":"95607da65d299418","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" will simply","item_id":"95607da65d299418","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":" output the string","item_id":"95607da65d299418","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"content_index":0,"delta":".\n","item_id":"95607da65d299418","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} ' - ' @@ -2688,10 +5108,15 @@ turns: - 'event: response.reasoning_text.done ' - - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"sequence_number":32,"text":"The - user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo - more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI - need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"response.reasoning_text.done"} + - 'data: {"content_index":0,"item_id":"95607da65d299418","output_index":0,"sequence_number":63,"text":"The + user wants me to output a specific string \"PARIS_MIXED_TOOLS_OK\" after using + the results from the previous tool calls.\nI have called `get_weather` for Paris, + which returned `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\nI + have called `get_timezone` for Paris (via `travel` namespace), which returned + `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\nThe user instructed + to use both outputs and call no more tools, then reply with exactly \"PARIS_MIXED_TOOLS_OK\".\n\nThe + request is straightforward: output the specific string. No further tool usage + is permitted.\nI will simply output the string.\n","type":"response.reasoning_text.done"} ' - ' @@ -2700,10 +5125,15 @@ turns: - 'event: response.reasoning_part.done ' - - 'data: {"content_index":0,"item_id":"905f67d33894f159","output_index":0,"part":{"text":"The - user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo - more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI - need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"},"sequence_number":33,"type":"response.reasoning_part.done"} + - 'data: {"content_index":0,"item_id":"95607da65d299418","output_index":0,"part":{"text":"The + user wants me to output a specific string \"PARIS_MIXED_TOOLS_OK\" after using + the results from the previous tool calls.\nI have called `get_weather` for Paris, + which returned `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\nI + have called `get_timezone` for Paris (via `travel` namespace), which returned + `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\nThe user instructed + to use both outputs and call no more tools, then reply with exactly \"PARIS_MIXED_TOOLS_OK\".\n\nThe + request is straightforward: output the specific string. No further tool usage + is permitted.\nI will simply output the string.\n","type":"reasoning_text"},"sequence_number":64,"type":"response.reasoning_part.done"} ' - ' @@ -2712,10 +5142,15 @@ turns: - 'event: response.output_item.done ' - - 'data: {"item":{"id":"905f67d33894f159","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo - more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI - need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":34,"type":"response.output_item.done"} + - 'data: {"item":{"id":"95607da65d299418","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to output a specific string \"PARIS_MIXED_TOOLS_OK\" after using + the results from the previous tool calls.\nI have called `get_weather` for Paris, + which returned `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\nI + have called `get_timezone` for Paris (via `travel` namespace), which returned + `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\nThe user instructed + to use both outputs and call no more tools, then reply with exactly \"PARIS_MIXED_TOOLS_OK\".\n\nThe + request is straightforward: output the specific string. No further tool usage + is permitted.\nI will simply output the string.\n","type":"reasoning_text"}],"encrypted_content":null,"status":"completed"},"output_index":0,"sequence_number":65,"type":"response.output_item.done"} ' - ' @@ -2724,7 +5159,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"item":{"id":"8e30a80425578155","content":[],"role":"assistant","status":"in_progress","type":"message","phase":null},"output_index":1,"sequence_number":35,"type":"response.output_item.added"} + - 'data: {"item":{"id":"85ede2e328bb837a","content":[],"role":"assistant","status":"in_progress","type":"message","phase":null},"output_index":1,"sequence_number":66,"type":"response.output_item.added"} ' - ' @@ -2733,7 +5168,16 @@ turns: - 'event: response.content_part.added ' - - 'data: {"content_index":0,"item_id":"8e30a80425578155","output_index":1,"part":{"annotations":[],"text":"","type":"output_text","logprobs":[]},"sequence_number":36,"type":"response.content_part.added"} + - 'data: {"content_index":0,"item_id":"85ede2e328bb837a","output_index":1,"part":{"annotations":[],"text":"","type":"output_text","logprobs":[]},"sequence_number":67,"type":"response.content_part.added"} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"content_index":0,"delta":"\n\nPARIS","item_id":"85ede2e328bb837a","logprobs":[],"output_index":1,"sequence_number":68,"type":"response.output_text.delta"} ' - ' @@ -2742,7 +5186,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"content_index":0,"delta":"\n\nPARIS","item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":37,"type":"response.output_text.delta"} + - 'data: {"content_index":0,"delta":"_MIXED_TO","item_id":"85ede2e328bb837a","logprobs":[],"output_index":1,"sequence_number":69,"type":"response.output_text.delta"} ' - ' @@ -2751,7 +5195,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"content_index":0,"delta":"_WEATHER_OK","item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":38,"type":"response.output_text.delta"} + - 'data: {"content_index":0,"delta":"OLS_OK","item_id":"85ede2e328bb837a","logprobs":[],"output_index":1,"sequence_number":70,"type":"response.output_text.delta"} ' - ' @@ -2760,7 +5204,7 @@ turns: - 'event: response.output_text.done ' - - 'data: {"content_index":0,"item_id":"8e30a80425578155","logprobs":[],"output_index":1,"sequence_number":39,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"} + - 'data: {"content_index":0,"item_id":"85ede2e328bb837a","logprobs":[],"output_index":1,"sequence_number":71,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"response.output_text.done"} ' - ' @@ -2769,7 +5213,7 @@ turns: - 'event: response.content_part.done ' - - 'data: {"content_index":0,"item_id":"8e30a80425578155","output_index":1,"part":{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null},"sequence_number":40,"type":"response.content_part.done"} + - 'data: {"content_index":0,"item_id":"85ede2e328bb837a","output_index":1,"part":{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text","logprobs":null},"sequence_number":72,"type":"response.content_part.done"} ' - ' @@ -2778,7 +5222,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"item":{"id":"8e30a80425578155","content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null,"summary":[]},"output_index":1,"sequence_number":41,"type":"response.output_item.done"} + - 'data: {"item":{"id":"85ede2e328bb837a","content":[{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null,"summary":[]},"output_index":1,"sequence_number":73,"type":"response.output_item.done"} ' - ' @@ -2787,14 +5231,21 @@ turns: - 'event: response.completed ' - - 'data: {"response":{"id":"resp_a185021a83a4d2e5","created_at":1787143230,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_985f3beffd5ba701","summary":[],"type":"reasoning","content":[{"text":"The - user wants me to use the function output and reply with exactly \"PARIS_WEATHER_OK\".\nNo - more tools should be called.\nThe function output for Paris is: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nI - need to output exactly \"PARIS_WEATHER_OK\".\nDone.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"id":"msg_9a4dd17402285ad2","content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search - the client tool catalog. Available catalog entry: get_weather — Get the current - weather for a city.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get - the current weather for a city.","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":639,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":76,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":715},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":42,"type":"response.completed"} + - 'data: {"response":{"id":"resp_a9fb3e0ad5ebdc49","created_at":1787714237,"incomplete_details":null,"instructions":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"id":"rs_aa5a758a97048417","summary":[],"type":"reasoning","content":[{"text":"The + user wants me to output a specific string \"PARIS_MIXED_TOOLS_OK\" after using + the results from the previous tool calls.\nI have called `get_weather` for Paris, + which returned `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\nI + have called `get_timezone` for Paris (via `travel` namespace), which returned + `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\nThe user instructed + to use both outputs and call no more tools, then reply with exactly \"PARIS_MIXED_TOOLS_OK\".\n\nThe + request is straightforward: output the specific string. No further tool usage + is permitted.\nI will simply output the string.\n","type":"reasoning_text"}],"encrypted_content":null,"status":null},{"id":"msg_90bf446e8768746d","content":[{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message","phase":null}],"parallel_tool_calls":false,"temperature":1.0,"tool_choice":"none","tools":[{"name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Search + the client tool catalog for tools that can satisfy the request. Available catalog + entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.","output_schema":null},{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the current weather for a city","output_schema":null},{"name":"agentic_ns__travel__get_timezone","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true,"type":"function","allowed_callers":null,"defer_loading":null,"description":"Get + the IANA time zone for a city","output_schema":null}],"top_p":0.95,"background":false,"max_output_tokens":4096,"max_tool_calls":null,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"completed","text":null,"top_logprobs":null,"truncation":"disabled","usage":{"input_tokens":405,"input_tokens_details":{"cached_tokens":0,"input_tokens_per_turn":[],"cached_tokens_per_turn":[]},"output_tokens":156,"output_tokens_details":{"reasoning_tokens":0,"tool_output_tokens":0,"output_tokens_per_turn":[],"tool_output_tokens_per_turn":[]},"total_tokens":561},"user":null,"presence_penalty":0.0,"frequency_penalty":0.0,"kv_transfer_params":null,"input_messages":null,"output_messages":null},"sequence_number":74,"type":"response.completed"} ' - ' diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml index a323acc0..573afda8 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml @@ -3,8 +3,8 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message max_output_tokens: 4096 @@ -12,23 +12,23 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: required tools: - - description: Search the client tool catalog for a tool that can satisfy the + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -40,6 +40,81 @@ turns: type: object strict: true type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace headers: accept: '*/*' content-type: application/json @@ -50,57 +125,171 @@ turns: response: body: conversation_id: null - created_at: 1787143209 + created_at: 1787715672 error: null - id: resp_01a01a09-3976-7c02-8339-d626463f9f50 + id: resp_01a03c28-5740-7b72-8265-d1eb9060a4d3 incomplete_details: null instructions: null model: Qwen/Qwen3.6-35B-A3B-FP8 object: response output: - content: - - text: "The user wants me to call `tool_search` exactly once with a query\ - \ related to finding a weather tool.\nI should not call `get_weather`\ - \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ - \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ - \ \"Get the current weather\".\nI will call `tool_search` with query \"\ - Get the current weather\".\nNo other steps needed.\nI will generate the\ - \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ - \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ - \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ - weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ - First call tool_search exactly once to find a weather tool.\"\nI will\ - \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ - Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ - \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ - weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ - The instruction says \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ - Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ - Calling tool_search...\n" + - text: 'The user wants me to call `tool_search` exactly once to find two + specific functions: + + 1. A current-weather function + + 2. A travel time-zone function + + + I need to construct a query that searches for both of these capabilities. + The available tools are: + + - get_weather — Get the current weather for a city + + - get_exchange_rate — Get the exchange rate between two currencies + + - search_hotels — Search for hotels in a city + + - travel — Travel location tools + + + The description for `tool_search` is "A concise description of the needed + capabilities." + + + I will search for "current weather and travel time zone functions". + + + Let''s call `tool_search`. + + ' type: reasoning_text encrypted_content: null - id: rs_ad4906c13b6d5210 + id: rs_b4aa8121e219577a status: null summary: [] type: reasoning - arguments: - query: weather - call_id: chatcmpl-tool-814996d1dda1ff69 + query: current weather and travel time zone functions + call_id: chatcmpl-tool-b47ff224c79fca2e execution: client - id: tsc_b68f545efa494bdf + id: tsc_b6e20137e8f66e99 status: completed type: tool_search_call previous_response_id: null status: completed + tool_choice: required + tools: + - description: Search the client tool catalog for tools that can satisfy the + request. + execution: client + parameters: + additionalProperties: false + properties: + query: + description: A concise description of the needed capabilities. + type: string + required: + - query + type: object + type: tool_search + - defer_loading: true + description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace usage: - input_tokens: 357 + input_tokens: 394 input_tokens_details: cached_tokens: 0 - output_tokens: 364 + output_tokens: 176 output_tokens_details: reasoning_tokens: 0 - total_tokens: 721 + total_tokens: 570 headers: content-type: application/json status_code: 200 @@ -108,49 +297,60 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message - content: - - text: "The user wants me to call `tool_search` exactly once with a query\ - \ related to finding a weather tool.\nI should not call `get_weather`\ - \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ - \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ - \ \"Get the current weather\".\nI will call `tool_search` with query \"\ - Get the current weather\".\nNo other steps needed.\nI will generate the\ - \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ - \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ - \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ - weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ - First call tool_search exactly once to find a weather tool.\"\nI will\ - \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ - Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ - \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ - weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ - The instruction says \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ - Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ - Calling tool_search...\n" + - text: 'The user wants me to call `tool_search` exactly once to find two + specific functions: + + 1. A current-weather function + + 2. A travel time-zone function + + + I need to construct a query that searches for both of these capabilities. + The available tools are: + + - get_weather — Get the current weather for a city + + - get_exchange_rate — Get the exchange rate between two currencies + + - search_hotels — Search for hotels in a city + + - travel — Travel location tools + + + The description for `tool_search` is "A concise description of the needed + capabilities." + + + I will search for "current weather and travel time zone functions". + + + Let''s call `tool_search`. + + ' type: reasoning_text encrypted_content: null - id: rs_ad4906c13b6d5210 + id: rs_b4aa8121e219577a status: null summary: [] type: reasoning - arguments: - query: weather - call_id: chatcmpl-tool-814996d1dda1ff69 + query: current weather and travel time zone functions + call_id: chatcmpl-tool-b47ff224c79fca2e execution: client - id: tsc_b68f545efa494bdf + id: tsc_b6e20137e8f66e99 status: completed type: tool_search_call - - call_id: chatcmpl-tool-814996d1dda1ff69 + - call_id: chatcmpl-tool-b47ff224c79fca2e execution: client status: completed tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -162,9 +362,26 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace type: tool_search_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message max_output_tokens: 4096 @@ -172,7 +389,9 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: + name: get_weather + type: function headers: accept: '*/*' content-type: application/json @@ -183,49 +402,77 @@ turns: response: body: conversation_id: null - created_at: 1787143209 + created_at: 1787715691 error: null - id: resp_01a01a09-4133-79a2-9d5d-1e3b3de51748 + id: resp_01a03c28-a046-7840-bb97-d07564b3d6fb incomplete_details: null instructions: null model: Qwen/Qwen3.6-35B-A3B-FP8 object: response output: - content: - - text: 'The user wants me to call the `get_weather` tool with the argument - `{"city":"Paris"}`. - - I must follow the instruction to call it exactly once and not call `tool_search` - again. + - text: 'The user wants me to call the `get_weather` function with the parameter + `{"city": "Paris"}`. - The previous `tool_search` result confirmed the existence of the `get_weather` - tool. + I have already identified the `get_weather` tool in the previous turn. - I will now proceed with calling `get_weather`. + I will now execute the tool call. ' type: reasoning_text encrypted_content: null - id: rs_911e09fba32ab331 + id: rs_9c0f99a8880fe9b1 status: null summary: [] type: reasoning - arguments: '{"city": "Paris"}' - call_id: chatcmpl-tool-97872ecbe52a4fe5 - id: fc_8a4c66d835ca3882 + call_id: chatcmpl-tool-a88ee0fcceb5cf7f + id: fc_966095bd4ccd6d4f name: get_weather status: completed type: function_call previous_response_id: null status: completed + tool_choice: + name: get_weather + type: function + tools: + - description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace usage: - input_tokens: 443 + input_tokens: 574 input_tokens_details: cached_tokens: 0 - output_tokens: 102 + output_tokens: 77 output_tokens_details: reasoning_tokens: 0 - total_tokens: 545 + total_tokens: 651 headers: content-type: application/json status_code: 200 @@ -233,49 +480,60 @@ turns: request: body: input: - - content: First call tool_search exactly once to find a weather tool. Do not - call get_weather yet. + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. role: user type: message - content: - - text: "The user wants me to call `tool_search` exactly once with a query\ - \ related to finding a weather tool.\nI should not call `get_weather`\ - \ yet.\nThe available tool is `get_weather`.\nI need to construct the\ - \ `tool_search` call.\nQuery: \"weather\" or \"get weather\".\nLet's use\ - \ \"Get the current weather\".\nI will call `tool_search` with query \"\ - Get the current weather\".\nNo other steps needed.\nI will generate the\ - \ tool call. \nWait, checking the prompt: \"First call tool_search exactly\ - \ once to find a weather tool. Do not call get_weather yet.\"\nI will\ - \ proceed. \nQuery: \"weather\" or \"get weather tool\". \nLet's use \"\ - weather\".\nDone. \nGenerating response. \nActually, the prompt says \"\ - First call tool_search exactly once to find a weather tool.\"\nI will\ - \ call `tool_search` with `query: \"weather\"`.\nProceed. \n[Output Generation]\n\ - Call `tool_search` with `query=\"weather\"`.\nCheck tool definition: `tool_search`\ - \ takes `query` (string).\nReady. \nProceed. \n[Tool Call]\n`tool_search(query=\"\ - weather\")`\nDone. \nI will output the tool call now. \n(Self-Correction/Verification)\n\ - The instruction says \"First call tool_search exactly once to find a weather\ - \ tool. Do not call get_weather yet.\"\nSo I just output the tool call.\n\ - Query should be concise. \"weather\" works.\nDone. \nProceeding. \n[Output]\n\ - Calling tool_search...\n" + - text: 'The user wants me to call `tool_search` exactly once to find two + specific functions: + + 1. A current-weather function + + 2. A travel time-zone function + + + I need to construct a query that searches for both of these capabilities. + The available tools are: + + - get_weather — Get the current weather for a city + + - get_exchange_rate — Get the exchange rate between two currencies + + - search_hotels — Search for hotels in a city + + - travel — Travel location tools + + + The description for `tool_search` is "A concise description of the needed + capabilities." + + + I will search for "current weather and travel time zone functions". + + + Let''s call `tool_search`. + + ' type: reasoning_text encrypted_content: null - id: rs_ad4906c13b6d5210 + id: rs_b4aa8121e219577a status: null summary: [] type: reasoning - arguments: - query: weather - call_id: chatcmpl-tool-814996d1dda1ff69 + query: current weather and travel time zone functions + call_id: chatcmpl-tool-b47ff224c79fca2e execution: client - id: tsc_b68f545efa494bdf + id: tsc_b6e20137e8f66e99 status: completed type: tool_search_call - - call_id: chatcmpl-tool-814996d1dda1ff69 + - call_id: chatcmpl-tool-b47ff224c79fca2e execution: client status: completed tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -287,41 +545,307 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace type: tool_search_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message - content: - - text: 'The user wants me to call the `get_weather` tool with the argument - `{"city":"Paris"}`. + - text: 'The user wants me to call the `get_weather` function with the parameter + `{"city": "Paris"}`. + + I have already identified the `get_weather` tool in the previous turn. + + I will now execute the tool call. - I must follow the instruction to call it exactly once and not call `tool_search` - again. + ' + type: reasoning_text + encrypted_content: null + id: rs_9c0f99a8880fe9b1 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-a88ee0fcceb5cf7f + id: fc_966095bd4ccd6d4f + name: get_weather + status: completed + type: function_call + - call_id: chatcmpl-tool-a88ee0fcceb5cf7f + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + store: false + stream: false + tool_choice: + name: get_timezone + namespace: travel + type: function + 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: 1787715691 + error: null + id: resp_01a03c28-a30c-7792-b6c5-17d8adab07de + incomplete_details: null + instructions: null + model: Qwen/Qwen3.6-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to call the `travel` namespace member `get_timezone` + with the city "Paris". - The previous `tool_search` result confirmed the existence of the `get_weather` - tool. + Looking at the available tools, I see `agentic_ns__travel__get_timezone` + which matches the description "Get the IANA time zone for a city". - I will now proceed with calling `get_weather`. + I need to call it exactly once with `{"city": "Paris"}`. + + Parameters: `city` -> "Paris" + + I will make the tool call. ' type: reasoning_text encrypted_content: null - id: rs_911e09fba32ab331 + id: rs_936b2d8d2c03b5cf status: null summary: [] type: reasoning - arguments: '{"city": "Paris"}' - call_id: chatcmpl-tool-97872ecbe52a4fe5 - id: fc_8a4c66d835ca3882 + call_id: chatcmpl-tool-a7f43fe38859c312 + id: fc_8239241de8662055 + name: get_timezone + namespace: travel + status: completed + type: function_call + previous_response_id: null + status: completed + tool_choice: + name: get_timezone + namespace: travel + type: function + tools: + - description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace + usage: + input_tokens: 658 + input_tokens_details: + cached_tokens: 0 + output_tokens: 128 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 786 + headers: + content-type: application/json + status_code: 200 +- filename: t4 + request: + body: + input: + - content: First call tool_search exactly once to find both a current-weather + function and a travel time-zone function. Do not call any other tool yet. + role: user + type: message + - content: + - text: 'The user wants me to call `tool_search` exactly once to find two + specific functions: + + 1. A current-weather function + + 2. A travel time-zone function + + + I need to construct a query that searches for both of these capabilities. + The available tools are: + + - get_weather — Get the current weather for a city + + - get_exchange_rate — Get the exchange rate between two currencies + + - search_hotels — Search for hotels in a city + + - travel — Travel location tools + + + The description for `tool_search` is "A concise description of the needed + capabilities." + + + I will search for "current weather and travel time zone functions". + + + Let''s call `tool_search`. + + ' + type: reasoning_text + encrypted_content: null + id: rs_b4aa8121e219577a + status: null + summary: [] + type: reasoning + - arguments: + query: current weather and travel time zone functions + call_id: chatcmpl-tool-b47ff224c79fca2e + execution: client + id: tsc_b6e20137e8f66e99 + status: completed + type: tool_search_call + - call_id: chatcmpl-tool-b47ff224c79fca2e + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + any other tool. + role: user + type: message + - content: + - text: 'The user wants me to call the `get_weather` function with the parameter + `{"city": "Paris"}`. + + I have already identified the `get_weather` tool in the previous turn. + + I will now execute the tool call. + + ' + type: reasoning_text + encrypted_content: null + id: rs_9c0f99a8880fe9b1 + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-a88ee0fcceb5cf7f + id: fc_966095bd4ccd6d4f name: get_weather status: completed type: function_call - - call_id: chatcmpl-tool-97872ecbe52a4fe5 + - call_id: chatcmpl-tool-a88ee0fcceb5cf7f output: '{"city":"Paris","condition":"clear","temperature_c":21}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + - content: + - text: 'The user wants me to call the `travel` namespace member `get_timezone` + with the city "Paris". + + Looking at the available tools, I see `agentic_ns__travel__get_timezone` + which matches the description "Get the IANA time zone for a city". + + I need to call it exactly once with `{"city": "Paris"}`. + + Parameters: `city` -> "Paris" + + I will make the tool call. + + ' + type: reasoning_text + encrypted_content: null + id: rs_936b2d8d2c03b5cf + status: null + summary: [] + type: reasoning + - arguments: '{"city": "Paris"}' + call_id: chatcmpl-tool-a7f43fe38859c312 + id: fc_8239241de8662055 + name: get_timezone + namespace: travel + status: completed + type: function_call + - call_id: chatcmpl-tool-a7f43fe38859c312 + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. role: user type: message max_output_tokens: 4096 @@ -329,7 +853,7 @@ turns: parallel_tool_calls: false store: false stream: false - tool_choice: auto + tool_choice: none headers: accept: '*/*' content-type: application/json @@ -340,28 +864,32 @@ turns: response: body: conversation_id: null - created_at: 1787143211 + created_at: 1787715692 error: null - id: resp_01a01a09-445a-7220-99bc-668e286b2deb + id: resp_01a03c28-a6fc-7303-b399-2272540c0c0b incomplete_details: null instructions: null model: Qwen/Qwen3.6-35B-A3B-FP8 object: response output: - content: - - text: "The user wants me to use the function output and reply with exactly\ - \ \"PARIS_WEATHER_OK\".\nThe previous function call returned: {\"city\"\ - :\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nThe user's instruction\ - \ is clear: \"Use the function output and call no more tools. Reply with\ - \ exactly PARIS_WEATHER_OK.\"\nSo I will just output \"PARIS_WEATHER_OK\"\ - .\nNo additional tools should be called.\nDone. \nWait, let's double check.\ - \ The prompt says \"Use the function output and call no more tools. Reply\ - \ with exactly PARIS_WEATHER_OK.\"\nThis is a simple instruction to just\ - \ output the exact string.\nI will output exactly \"PARIS_WEATHER_OK\"\ - .\nAll steps followed.\nProceeding. \nOutput matches requirement.✅\n" + - text: 'The user wants me to acknowledge the function outputs and reply with + a specific string, without calling any more tools. + + I have received outputs from `get_weather` (Paris, 21C, clear) and `travel` + (timezone Europe/Paris). + + The instruction is to reply with "PARIS_MIXED_TOOLS_OK". + + + Plan: + + 1. Output "PARIS_MIXED_TOOLS_OK". + + ' type: reasoning_text encrypted_content: null - id: rs_8c483526383fac76 + id: rs_9f78d7593151017c status: null summary: [] type: reasoning @@ -370,22 +898,52 @@ turns: text: ' - PARIS_WEATHER_OK' + PARIS_MIXED_TOOLS_OK' type: output_text - id: msg_be93fa93eeadeadd + id: msg_bb50d05c0887d441 role: assistant status: completed type: message previous_response_id: null status: completed + tool_choice: none + tools: + - description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace usage: - input_tokens: 520 + input_tokens: 346 input_tokens_details: cached_tokens: 0 - output_tokens: 172 + output_tokens: 95 output_tokens_details: reasoning_tokens: 0 - total_tokens: 692 + total_tokens: 441 headers: content-type: application/json status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml index f461915a..0755768c 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml @@ -2,30 +2,30 @@ turns: - filename: t1 request: body: - input: First call tool_search exactly once to find a weather tool. Do not call - get_weather yet. + input: First call tool_search exactly once to find both a current-weather function + and a travel time-zone function. Do not call any other tool yet. max_output_tokens: 4096 model: Qwen/Qwen3.6-35B-A3B-FP8 parallel_tool_calls: false store: true stream: true - tool_choice: auto + tool_choice: required tools: - - description: Search the client tool catalog for a tool that can satisfy the + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -37,6 +37,81 @@ turns: type: object strict: true type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace headers: accept: '*/*' content-type: application/json @@ -51,10 +126,16 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143213,"frequency_penalty":0.0,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715694,"frequency_penalty":0.0,"id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -63,10 +144,16 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143213,"frequency_penalty":0.0,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715694,"frequency_penalty":0.0,"id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -75,7 +162,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"a01a0b847a293fe9","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":"a715a17044c59c6f","status":"in_progress","summary":[],"type":"reasoning"}} ' - ' @@ -84,7 +171,7 @@ turns: - 'event: response.reasoning_part.added ' - - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","part":{"text":"","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"a715a17044c59c6f","part":{"text":"","type":"reasoning_text"}} ' - ' @@ -93,7 +180,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"a715a17044c59c6f"} ' - ' @@ -103,7 +190,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" - user wants me","item_id":"a01a0b847a293fe9"} + user wants me","item_id":"a715a17044c59c6f"} ' - ' @@ -113,7 +200,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" - to call `","item_id":"a01a0b847a293fe9"} + to search for","item_id":"a715a17044c59c6f"} ' - ' @@ -122,7 +209,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + two","item_id":"a715a17044c59c6f"} ' - ' @@ -132,7 +220,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" - exactly once to","item_id":"a01a0b847a293fe9"} + specific tools:","item_id":"a715a17044c59c6f"} ' - ' @@ -141,8 +229,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" - find a weather","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":"\n1.","item_id":"a715a17044c59c6f"} ' - ' @@ -152,7 +239,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" - tool.\n","item_id":"a01a0b847a293fe9"} + A current-","item_id":"a715a17044c59c6f"} ' - ' @@ -161,7 +248,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":"They","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":"weather + function\n","item_id":"a715a17044c59c6f"} ' - ' @@ -170,8 +258,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" - specifically","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":"2. + A","item_id":"a715a17044c59c6f"} ' - ' @@ -181,7 +269,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" - said \"","item_id":"a01a0b847a293fe9"} + travel time-zone","item_id":"a715a17044c59c6f"} ' - ' @@ -190,8 +278,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"Do - not call","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":" + function\n\nI","item_id":"a715a17044c59c6f"} ' - ' @@ -201,7 +289,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":" - get_weather yet","item_id":"a01a0b847a293fe9"} + need to use","item_id":"a715a17044c59c6f"} ' - ' @@ -210,7 +298,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":".\"\nThe","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + the `tool","item_id":"a715a17044c59c6f"} ' - ' @@ -219,8 +308,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" - `","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":"_search` + function","item_id":"a715a17044c59c6f"} ' - ' @@ -229,7 +318,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" + exactly once with","item_id":"a715a17044c59c6f"} ' - ' @@ -239,7 +329,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" - function takes a","item_id":"a01a0b847a293fe9"} + a query that","item_id":"a715a17044c59c6f"} ' - ' @@ -249,7 +339,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" - `query`","item_id":"a01a0b847a293fe9"} + describes","item_id":"a715a17044c59c6f"} ' - ' @@ -259,7 +349,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" - parameter.\n","item_id":"a01a0b847a293fe9"} + both capabilities.","item_id":"a715a17044c59c6f"} ' - ' @@ -268,8 +358,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"I - need to","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"\n\nLooking + at","item_id":"a715a17044c59c6f"} ' - ' @@ -279,7 +369,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" - search for a","item_id":"a01a0b847a293fe9"} + the available tool","item_id":"a715a17044c59c6f"} ' - ' @@ -289,7 +379,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" - weather tool.","item_id":"a01a0b847a293fe9"} + catalog","item_id":"a715a17044c59c6f"} ' - ' @@ -298,7 +388,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":"\n\n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + descriptions","item_id":"a715a17044c59c6f"} ' - ' @@ -307,7 +398,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"Parameters","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":":\n-","item_id":"a715a17044c59c6f"} ' - ' @@ -316,7 +407,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":":","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":" + `","item_id":"a715a17044c59c6f"} ' - ' @@ -325,7 +417,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"\n-","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"get_weather`","item_id":"a715a17044c59c6f"} ' - ' @@ -335,7 +427,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" - query: \"","item_id":"a01a0b847a293fe9"} + — Get the","item_id":"a715a17044c59c6f"} ' - ' @@ -344,8 +436,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":"weather - tool\"","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" + current weather for","item_id":"a715a17044c59c6f"} ' - ' @@ -355,7 +447,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" - or \"get","item_id":"a01a0b847a293fe9"} + a city\n","item_id":"a715a17044c59c6f"} ' - ' @@ -364,8 +456,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" - weather\"\n\n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":"- + `get","item_id":"a715a17044c59c6f"} ' - ' @@ -374,8 +466,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":"Let''s - construct","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":"_exchange_rate`","item_id":"a715a17044c59c6f"} ' - ' @@ -385,7 +476,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" - the `","item_id":"a01a0b847a293fe9"} + — Get the","item_id":"a715a17044c59c6f"} ' - ' @@ -394,7 +485,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + exchange rate between","item_id":"a715a17044c59c6f"} ' - ' @@ -404,7 +496,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" - call.\n","item_id":"a01a0b847a293fe9"} + two currencies\n","item_id":"a715a17044c59c6f"} ' - ' @@ -413,7 +505,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":"`","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":"- + `search","item_id":"a715a17044c59c6f"} ' - ' @@ -422,7 +515,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"tool_search(query","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"_hotels`","item_id":"a715a17044c59c6f"} ' - ' @@ -431,8 +524,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":"=\"weather - tool","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + — Search for","item_id":"a715a17044c59c6f"} ' - ' @@ -441,8 +534,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":"\")` - or","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + hotels in a","item_id":"a715a17044c59c6f"} ' - ' @@ -452,7 +545,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" - `tool_search","item_id":"a01a0b847a293fe9"} + city\n-","item_id":"a715a17044c59c6f"} ' - ' @@ -461,7 +554,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":"(query=\"get","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + `travel`","item_id":"a715a17044c59c6f"} ' - ' @@ -471,7 +565,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" - weather","item_id":"a01a0b847a293fe9"} + — Travel location","item_id":"a715a17044c59c6f"} ' - ' @@ -480,7 +574,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":"\")`\n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":" + tools.","item_id":"a715a17044c59c6f"} ' - ' @@ -489,8 +584,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"The - description says","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"\n\nThe + `","item_id":"a715a17044c59c6f"} ' - ' @@ -499,7 +594,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":":","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":"get","item_id":"a715a17044c59c6f"} ' - ' @@ -508,8 +603,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":" - \"get","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":"_weather` + tool","item_id":"a715a17044c59c6f"} ' - ' @@ -518,8 +613,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":"_weather - — Get","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":" + matches \"","item_id":"a715a17044c59c6f"} ' - ' @@ -528,8 +623,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":" - the current weather","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":"current-weather","item_id":"a715a17044c59c6f"} ' - ' @@ -539,7 +633,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" - for a city","item_id":"a01a0b847a293fe9"} + function\".\n","item_id":"a715a17044c59c6f"} ' - ' @@ -548,7 +642,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":".\"\nI","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":"The + `travel","item_id":"a715a17044c59c6f"} ' - ' @@ -557,8 +652,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" - will use `","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":"` + tool likely","item_id":"a715a17044c59c6f"} ' - ' @@ -567,7 +662,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":"query","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":" + contains","item_id":"a715a17044c59c6f"} ' - ' @@ -576,7 +672,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":"=\"weather\"","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":" + or","item_id":"a715a17044c59c6f"} ' - ' @@ -585,7 +682,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":"`.\nExecuting","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":" + is","item_id":"a715a17044c59c6f"} ' - ' @@ -595,7 +693,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":" - tool","item_id":"a01a0b847a293fe9"} + related to \"","item_id":"a715a17044c59c6f"} ' - ' @@ -604,8 +702,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":" - call. \n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":"travel + time-zone","item_id":"a715a17044c59c6f"} ' - ' @@ -614,8 +712,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":"Wait, - I","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":" + function\" based","item_id":"a715a17044c59c6f"} ' - ' @@ -625,7 +723,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":" - should just use","item_id":"a01a0b847a293fe9"} + on the description","item_id":"a715a17044c59c6f"} ' - ' @@ -635,7 +733,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":" - `","item_id":"a01a0b847a293fe9"} + \"","item_id":"a715a17044c59c6f"} ' - ' @@ -644,7 +742,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":"Travel + location tools","item_id":"a715a17044c59c6f"} ' - ' @@ -653,8 +752,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" - with `","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":"\".\n\nI","item_id":"a715a17044c59c6f"} ' - ' @@ -663,7 +761,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":"query=\"weather","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + will formulate a","item_id":"a715a17044c59c6f"} ' - ' @@ -672,7 +771,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":"\"`.\n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":" + query that searches","item_id":"a715a17044c59c6f"} ' - ' @@ -681,8 +781,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":"Done. - \n","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":" + for both.","item_id":"a715a17044c59c6f"} ' - ' @@ -691,7 +791,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"Output","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":" + \"","item_id":"a715a17044c59c6f"} ' - ' @@ -700,8 +801,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":" - matches the","item_id":"a01a0b847a293fe9"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":"current","item_id":"a715a17044c59c6f"} ' - ' @@ -711,7 +811,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":68,"output_index":0,"content_index":0,"delta":" - required","item_id":"a01a0b847a293fe9"} + weather function and","item_id":"a715a17044c59c6f"} ' - ' @@ -721,7 +821,64 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":69,"output_index":0,"content_index":0,"delta":" - format.\n","item_id":"a01a0b847a293fe9"} + travel time-zone","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":70,"output_index":0,"content_index":0,"delta":" + function\".","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":71,"output_index":0,"content_index":0,"delta":"\n\nLet''s","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":72,"output_index":0,"content_index":0,"delta":" + call `","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":73,"output_index":0,"content_index":0,"delta":"tool_search`","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":74,"output_index":0,"content_index":0,"delta":" + with this query","item_id":"a715a17044c59c6f"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":75,"output_index":0,"content_index":0,"delta":".\n","item_id":"a715a17044c59c6f"} ' - ' @@ -730,15 +887,18 @@ turns: - 'event: response.reasoning_text.done ' - - 'data: {"type":"response.reasoning_text.done","sequence_number":70,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nThey - specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function - takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- - query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` - call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe - description says: \"get_weather — Get the current weather for a city.\"\nI will - use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` - with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n"} + - 'data: {"type":"response.reasoning_text.done","sequence_number":76,"output_index":0,"content_index":0,"item_id":"a715a17044c59c6f","text":"The + user wants me to search for two specific tools:\n1. A current-weather function\n2. + A travel time-zone function\n\nI need to use the `tool_search` function exactly + once with a query that describes both capabilities.\n\nLooking at the available + tool catalog descriptions:\n- `get_weather` — Get the current weather for a + city\n- `get_exchange_rate` — Get the exchange rate between two currencies\n- + `search_hotels` — Search for hotels in a city\n- `travel` — Travel location + tools.\n\nThe `get_weather` tool matches \"current-weather function\".\nThe + `travel` tool likely contains or is related to \"travel time-zone function\" + based on the description \"Travel location tools\".\n\nI will formulate a query + that searches for both. \"current weather function and travel time-zone function\".\n\nLet''s + call `tool_search` with this query.\n"} ' - ' @@ -747,15 +907,18 @@ turns: - 'event: response.reasoning_part.done ' - - 'data: {"type":"response.reasoning_part.done","sequence_number":71,"output_index":0,"content_index":0,"item_id":"a01a0b847a293fe9","part":{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nThey - specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function - takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- - query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` - call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe - description says: \"get_weather — Get the current weather for a city.\"\nI will - use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` - with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.done","sequence_number":77,"output_index":0,"content_index":0,"item_id":"a715a17044c59c6f","part":{"text":"The + user wants me to search for two specific tools:\n1. A current-weather function\n2. + A travel time-zone function\n\nI need to use the `tool_search` function exactly + once with a query that describes both capabilities.\n\nLooking at the available + tool catalog descriptions:\n- `get_weather` — Get the current weather for a + city\n- `get_exchange_rate` — Get the exchange rate between two currencies\n- + `search_hotels` — Search for hotels in a city\n- `travel` — Travel location + tools.\n\nThe `get_weather` tool matches \"current-weather function\".\nThe + `travel` tool likely contains or is related to \"travel time-zone function\" + based on the description \"Travel location tools\".\n\nI will formulate a query + that searches for both. \"current weather function and travel time-zone function\".\n\nLet''s + call `tool_search` with this query.\n","type":"reasoning_text"}} ' - ' @@ -764,15 +927,18 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":72,"output_index":0,"item":{"content":[{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nThey - specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function - takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- - query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` - call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe - description says: \"get_weather — Get the current weather for a city.\"\nI will - use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` - with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a01a0b847a293fe9","status":"completed","summary":[],"type":"reasoning"}} + - 'data: {"type":"response.output_item.done","sequence_number":78,"output_index":0,"item":{"content":[{"text":"The + user wants me to search for two specific tools:\n1. A current-weather function\n2. + A travel time-zone function\n\nI need to use the `tool_search` function exactly + once with a query that describes both capabilities.\n\nLooking at the available + tool catalog descriptions:\n- `get_weather` — Get the current weather for a + city\n- `get_exchange_rate` — Get the exchange rate between two currencies\n- + `search_hotels` — Search for hotels in a city\n- `travel` — Travel location + tools.\n\nThe `get_weather` tool matches \"current-weather function\".\nThe + `travel` tool likely contains or is related to \"travel time-zone function\" + based on the description \"Travel location tools\".\n\nI will formulate a query + that searches for both. \"current weather function and travel time-zone function\".\n\nLet''s + call `tool_search` with this query.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a715a17044c59c6f","status":"completed","summary":[],"type":"reasoning"}} ' - ' @@ -781,7 +947,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":73,"output_index":1,"item":{"arguments":{},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"in_progress","type":"tool_search_call"}} + - 'data: {"type":"response.output_item.added","sequence_number":79,"output_index":1,"item":{"arguments":{},"call_id":"call_9dbedda8b73d4737","execution":"client","id":"tsc_0e6b70f150f01d44","status":"in_progress","type":"tool_search_call"}} ' - ' @@ -790,7 +956,8 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":74,"output_index":1,"item":{"arguments":{"query":"weather"},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"completed","type":"tool_search_call"}} + - 'data: {"type":"response.output_item.done","sequence_number":80,"output_index":1,"item":{"arguments":{"query":"current + weather function and travel time-zone function"},"call_id":"call_9dbedda8b73d4737","execution":"client","id":"tsc_0e6b70f150f01d44","status":"completed","type":"tool_search_call"}} ' - ' @@ -799,15 +966,28 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","sequence_number":75,"response":{"conversation_id":null,"created_at":1787143214,"error":null,"id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call `tool_search` exactly once to find a weather tool.\nThey - specifically said \"Do not call get_weather yet.\"\nThe `tool_search` function - takes a `query` parameter.\nI need to search for a weather tool.\n\nParameters:\n- - query: \"weather tool\" or \"get weather\"\n\nLet''s construct the `tool_search` - call.\n`tool_search(query=\"weather tool\")` or `tool_search(query=\"get weather\")`\nThe - description says: \"get_weather — Get the current weather for a city.\"\nI will - use `query=\"weather\"`.\nExecuting tool call. \nWait, I should just use `tool_search` - with `query=\"weather\"`.\nDone. \nOutput matches the required format.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a01a0b847a293fe9","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"weather"},"call_id":"call_9cb73148dea9cad4","execution":"client","id":"tsc_367e92d785e2f586","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":190,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":547}}} + - 'data: {"type":"response.completed","sequence_number":81,"response":{"conversation_id":null,"created_at":1787715695,"error":null,"id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to search for two specific tools:\n1. A current-weather function\n2. + A travel time-zone function\n\nI need to use the `tool_search` function exactly + once with a query that describes both capabilities.\n\nLooking at the available + tool catalog descriptions:\n- `get_weather` — Get the current weather for a + city\n- `get_exchange_rate` — Get the exchange rate between two currencies\n- + `search_hotels` — Search for hotels in a city\n- `travel` — Travel location + tools.\n\nThe `get_weather` tool matches \"current-weather function\".\nThe + `travel` tool likely contains or is related to \"travel time-zone function\" + based on the description \"Travel location tools\".\n\nI will formulate a query + that searches for both. \"current weather function and travel time-zone function\".\n\nLet''s + call `tool_search` with this query.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a715a17044c59c6f","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"current + weather function and travel time-zone function"},"call_id":"call_9dbedda8b73d4737","execution":"client","id":"tsc_0e6b70f150f01d44","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","tool_choice":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":394,"input_tokens_details":{"cached_tokens":0},"output_tokens":219,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":613}}} ' - ' @@ -824,12 +1004,12 @@ turns: request: body: input: - - call_id: call_9cb73148dea9cad4 + - call_id: call_9dbedda8b73d4737 execution: client status: completed tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -841,18 +1021,37 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace type: tool_search_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message max_output_tokens: 4096 model: Qwen/Qwen3.6-35B-A3B-FP8 parallel_tool_calls: false - previous_response_id: resp_01a01a09-501c-7c80-b65a-9e2977a11bd6 + previous_response_id: resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14 store: true stream: true - tool_choice: auto + tool_choice: + name: get_weather + type: function headers: accept: '*/*' content-type: application/json @@ -867,10 +1066,10 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143214,"frequency_penalty":0.0,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715696,"frequency_penalty":0.0,"id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -879,10 +1078,10 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143214,"frequency_penalty":0.0,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715696,"frequency_penalty":0.0,"id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -891,7 +1090,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"9df21f12d14e7185","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":"b75ff4c3553a0416","status":"in_progress","summary":[],"type":"reasoning"}} ' - ' @@ -900,7 +1099,7 @@ turns: - 'event: response.reasoning_part.added ' - - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","part":{"text":"","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"b75ff4c3553a0416","part":{"text":"","type":"reasoning_text"}} ' - ' @@ -909,7 +1108,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"b75ff4c3553a0416"} ' - ' @@ -919,7 +1118,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" - user wants me","item_id":"9df21f12d14e7185"} + user wants me","item_id":"b75ff4c3553a0416"} ' - ' @@ -929,7 +1128,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" - to call the","item_id":"9df21f12d14e7185"} + to call `","item_id":"b75ff4c3553a0416"} ' - ' @@ -938,8 +1137,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" - `get_weather","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":"get_weather`","item_id":"b75ff4c3553a0416"} ' - ' @@ -948,8 +1146,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":"` - tool exactly","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + with the city","item_id":"b75ff4c3553a0416"} ' - ' @@ -959,7 +1157,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" - once with the","item_id":"9df21f12d14e7185"} + \"Paris\".","item_id":"b75ff4c3553a0416"} ' - ' @@ -969,7 +1167,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" - city","item_id":"9df21f12d14e7185"} + I","item_id":"b75ff4c3553a0416"} ' - ' @@ -979,7 +1177,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" - \"Paris\".","item_id":"9df21f12d14e7185"} + need","item_id":"b75ff4c3553a0416"} ' - ' @@ -988,8 +1186,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":"\nI - have","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + to make","item_id":"b75ff4c3553a0416"} ' - ' @@ -999,7 +1197,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" - already found the","item_id":"9df21f12d14e7185"} + sure I use","item_id":"b75ff4c3553a0416"} ' - ' @@ -1009,7 +1207,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":" - tool using `","item_id":"9df21f12d14e7185"} + the","item_id":"b75ff4c3553a0416"} ' - ' @@ -1018,7 +1216,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"tool_search`.","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":" + exact tool","item_id":"b75ff4c3553a0416"} ' - ' @@ -1027,7 +1226,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"\nThe","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + and","item_id":"b75ff4c3553a0416"} ' - ' @@ -1037,7 +1237,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" - parameters","item_id":"9df21f12d14e7185"} + parameters specified","item_id":"b75ff4c3553a0416"} ' - ' @@ -1046,8 +1246,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" - for `get","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":".\nTool","item_id":"b75ff4c3553a0416"} ' - ' @@ -1056,8 +1255,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"_weather` - are","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":": + `get","item_id":"b75ff4c3553a0416"} ' - ' @@ -1066,7 +1265,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":":\n","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":"_weather`\n","item_id":"b75ff4c3553a0416"} ' - ' @@ -1075,8 +1274,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"- - `city","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"Parameters: + `","item_id":"b75ff4c3553a0416"} ' - ' @@ -1085,8 +1284,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"`: - \"","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"{\"city\":","item_id":"b75ff4c3553a0416"} ' - ' @@ -1095,7 +1293,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":"Paris\"\n\n","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + \"Paris\"","item_id":"b75ff4c3553a0416"} ' - ' @@ -1104,8 +1303,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":"I - will proceed","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":"}`\n\n","item_id":"b75ff4c3553a0416"} ' - ' @@ -1114,8 +1312,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" - with the function","item_id":"9df21f12d14e7185"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":"I + will call","item_id":"b75ff4c3553a0416"} ' - ' @@ -1125,7 +1323,16 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" - call.\n","item_id":"9df21f12d14e7185"} + the tool now","item_id":"b75ff4c3553a0416"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":".\n","item_id":"b75ff4c3553a0416"} ' - ' @@ -1134,10 +1341,10 @@ turns: - 'event: response.reasoning_text.done ' - - 'data: {"type":"response.reasoning_text.done","sequence_number":27,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already found the tool using `tool_search`.\nThe parameters for `get_weather` - are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n"} + - 'data: {"type":"response.reasoning_text.done","sequence_number":28,"output_index":0,"content_index":0,"item_id":"b75ff4c3553a0416","text":"The + user wants me to call `get_weather` with the city \"Paris\". I need to make + sure I use the exact tool and parameters specified.\nTool: `get_weather`\nParameters: + `{\"city\": \"Paris\"}`\n\nI will call the tool now.\n"} ' - ' @@ -1146,10 +1353,10 @@ turns: - 'event: response.reasoning_part.done ' - - 'data: {"type":"response.reasoning_part.done","sequence_number":28,"output_index":0,"content_index":0,"item_id":"9df21f12d14e7185","part":{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already found the tool using `tool_search`.\nThe parameters for `get_weather` - are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.done","sequence_number":29,"output_index":0,"content_index":0,"item_id":"b75ff4c3553a0416","part":{"text":"The + user wants me to call `get_weather` with the city \"Paris\". I need to make + sure I use the exact tool and parameters specified.\nTool: `get_weather`\nParameters: + `{\"city\": \"Paris\"}`\n\nI will call the tool now.\n","type":"reasoning_text"}} ' - ' @@ -1158,10 +1365,10 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":29,"output_index":0,"item":{"content":[{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already found the tool using `tool_search`.\nThe parameters for `get_weather` - are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9df21f12d14e7185","status":"completed","summary":[],"type":"reasoning"}} + - 'data: {"type":"response.output_item.done","sequence_number":30,"output_index":0,"item":{"content":[{"text":"The + user wants me to call `get_weather` with the city \"Paris\". I need to make + sure I use the exact tool and parameters specified.\nTool: `get_weather`\nParameters: + `{\"city\": \"Paris\"}`\n\nI will call the tool now.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"b75ff4c3553a0416","status":"completed","summary":[],"type":"reasoning"}} ' - ' @@ -1170,7 +1377,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":30,"output_index":1,"item":{"arguments":"","call_id":"call_8860d1fc26b80da3","caller":null,"id":"a66196e5caa2e08b","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"}} + - 'data: {"type":"response.output_item.added","sequence_number":31,"output_index":1,"item":{"arguments":"","call_id":"call_86fcb582d200885e","caller":null,"id":"993aaf1391b2c681","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"}} ' - ' @@ -1179,8 +1386,8 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","sequence_number":31,"output_index":1,"delta":"{\"city\": - \"","item_id":"a66196e5caa2e08b"} + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":32,"output_index":1,"delta":"{\"city\": + \"","item_id":"993aaf1391b2c681"} ' - ' @@ -1189,7 +1396,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","sequence_number":32,"output_index":1,"delta":"Paris","item_id":"a66196e5caa2e08b"} + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":33,"output_index":1,"delta":"Paris","item_id":"993aaf1391b2c681"} ' - ' @@ -1198,7 +1405,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","sequence_number":33,"output_index":1,"delta":"\"}","item_id":"a66196e5caa2e08b"} + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":34,"output_index":1,"delta":"\"}","item_id":"993aaf1391b2c681"} ' - ' @@ -1207,8 +1414,8 @@ turns: - 'event: response.function_call_arguments.done ' - - 'data: {"type":"response.function_call_arguments.done","sequence_number":34,"output_index":1,"arguments":"{\"city\": - \"Paris\"}","item_id":"a66196e5caa2e08b","name":"get_weather"} + - 'data: {"type":"response.function_call_arguments.done","sequence_number":35,"output_index":1,"arguments":"{\"city\": + \"Paris\"}","item_id":"993aaf1391b2c681","name":"get_weather"} ' - ' @@ -1217,8 +1424,8 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":35,"output_index":1,"item":{"arguments":"{\"city\": - \"Paris\"}","call_id":"call_8860d1fc26b80da3","caller":null,"id":"a66196e5caa2e08b","name":"get_weather","namespace":null,"status":"completed","type":"function_call"}} + - 'data: {"type":"response.output_item.done","sequence_number":36,"output_index":1,"item":{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_86fcb582d200885e","caller":null,"id":"993aaf1391b2c681","name":"get_weather","namespace":null,"status":"completed","type":"function_call"}} ' - ' @@ -1227,11 +1434,14 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","sequence_number":36,"response":{"conversation_id":null,"created_at":1787143214,"error":null,"id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call the `get_weather` tool exactly once with the city \"Paris\".\nI - have already found the tool using `tool_search`.\nThe parameters for `get_weather` - are:\n- `city`: \"Paris\"\n\nI will proceed with the function call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9df21f12d14e7185","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": - \"Paris\"}","call_id":"call_8860d1fc26b80da3","id":"a66196e5caa2e08b","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-501c-7c80-b65a-9e2977a11bd6","status":"completed","usage":{"input_tokens":554,"input_tokens_details":{"cached_tokens":0},"output_tokens":88,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":642}}} + - 'data: {"type":"response.completed","sequence_number":37,"response":{"conversation_id":null,"created_at":1787715697,"error":null,"id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call `get_weather` with the city \"Paris\". I need to make + sure I use the exact tool and parameters specified.\nTool: `get_weather`\nParameters: + `{\"city\": \"Paris\"}`\n\nI will call the tool now.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"b75ff4c3553a0416","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_86fcb582d200885e","id":"993aaf1391b2c681","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-b034-7bd1-b5d2-5243c66a0a14","status":"completed","tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":718,"input_tokens_details":{"cached_tokens":0},"output_tokens":84,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":802}}} ' - ' @@ -1248,20 +1458,23 @@ turns: request: body: input: - - call_id: call_8860d1fc26b80da3 + - call_id: call_86fcb582d200885e output: '{"city":"Paris","condition":"clear","temperature_c":21}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. role: user type: message max_output_tokens: 4096 model: Qwen/Qwen3.6-35B-A3B-FP8 parallel_tool_calls: false - previous_response_id: resp_01a01a09-54b6-7171-8f20-ac680472ed04 + previous_response_id: resp_01a03c28-b7b8-76d1-93ce-33557fafbad7 store: true stream: true - tool_choice: auto + tool_choice: + name: get_timezone + namespace: travel + type: function headers: accept: '*/*' content-type: application/json @@ -1276,10 +1489,10 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787143215,"frequency_penalty":0.0,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715698,"frequency_penalty":0.0,"id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -1288,10 +1501,10 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787143215,"frequency_penalty":0.0,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"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":1787715698,"frequency_penalty":0.0,"id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} ' - ' @@ -1300,7 +1513,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"8fda27ec54402330","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":"a52b36823d861ad5","status":"in_progress","summary":[],"type":"reasoning"}} ' - ' @@ -1309,7 +1522,7 @@ turns: - 'event: response.reasoning_part.added ' - - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","part":{"text":"","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"a52b36823d861ad5","part":{"text":"","type":"reasoning_text"}} ' - ' @@ -1318,7 +1531,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"a52b36823d861ad5"} ' - ' @@ -1328,7 +1541,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" - user wants me","item_id":"8fda27ec54402330"} + user wants me","item_id":"a52b36823d861ad5"} ' - ' @@ -1338,7 +1551,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" - to use","item_id":"8fda27ec54402330"} + to call the","item_id":"a52b36823d861ad5"} ' - ' @@ -1348,7 +1561,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" - the function output","item_id":"8fda27ec54402330"} + `get_timezone","item_id":"a52b36823d861ad5"} ' - ' @@ -1357,8 +1570,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" - and call","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":"` + function from","item_id":"a52b36823d861ad5"} ' - ' @@ -1368,7 +1581,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" - no more tools","item_id":"8fda27ec54402330"} + the `travel","item_id":"a52b36823d861ad5"} ' - ' @@ -1377,7 +1590,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":".\nThen","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":"` + namespace,","item_id":"a52b36823d861ad5"} ' - ' @@ -1387,7 +1601,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" - I need","item_id":"8fda27ec54402330"} + which was","item_id":"a52b36823d861ad5"} ' - ' @@ -1397,7 +1611,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" - to reply with","item_id":"8fda27ec54402330"} + loaded in the","item_id":"a52b36823d861ad5"} ' - ' @@ -1407,7 +1621,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" - exactly \"PAR","item_id":"8fda27ec54402330"} + previous tool","item_id":"a52b36823d861ad5"} ' - ' @@ -1416,7 +1630,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"IS_WEATHER","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":" + search result","item_id":"a52b36823d861ad5"} ' - ' @@ -1425,7 +1640,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_OK\".\n","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":". + The","item_id":"a52b36823d861ad5"} ' - ' @@ -1434,7 +1650,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"I","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + parameters","item_id":"a52b36823d861ad5"} ' - ' @@ -1444,7 +1661,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" - have the","item_id":"8fda27ec54402330"} + should","item_id":"a52b36823d861ad5"} ' - ' @@ -1454,7 +1671,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" - weather","item_id":"8fda27ec54402330"} + be `{\"","item_id":"a52b36823d861ad5"} ' - ' @@ -1463,8 +1680,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" - for","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"city\": + \"","item_id":"a52b36823d861ad5"} ' - ' @@ -1473,8 +1690,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" - Paris: temperature","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":"Paris\"}`","item_id":"a52b36823d861ad5"} ' - ' @@ -1483,8 +1699,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" - 21","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":".\n","item_id":"a52b36823d861ad5"} ' - ' @@ -1493,8 +1708,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"°C, - clear","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"I + need to","item_id":"a52b36823d861ad5"} ' - ' @@ -1503,7 +1718,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":".\nI","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + make","item_id":"a52b36823d861ad5"} ' - ' @@ -1513,7 +1729,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" - will just reply","item_id":"8fda27ec54402330"} + sure I call","item_id":"a52b36823d861ad5"} ' - ' @@ -1523,7 +1739,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" - with \"PAR","item_id":"8fda27ec54402330"} + exactly","item_id":"a52b36823d861ad5"} ' - ' @@ -1532,7 +1748,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"IS_WEATHER","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" + this","item_id":"a52b36823d861ad5"} ' - ' @@ -1541,7 +1758,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":"_OK\".\n","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":" + tool and no","item_id":"a52b36823d861ad5"} ' - ' @@ -1550,7 +1768,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"Done","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" + others","item_id":"a52b36823d861ad5"} ' - ' @@ -1559,8 +1778,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":". - \n","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":".\nThe","item_id":"a52b36823d861ad5"} ' - ' @@ -1569,7 +1787,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":"Checking","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" + tool","item_id":"a52b36823d861ad5"} ' - ' @@ -1579,7 +1798,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" - constraints:\n","item_id":"8fda27ec54402330"} + is","item_id":"a52b36823d861ad5"} ' - ' @@ -1588,8 +1807,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":"- - Use","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" + `agentic","item_id":"a52b36823d861ad5"} ' - ' @@ -1598,8 +1817,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":" - the","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":"_ns__travel","item_id":"a52b36823d861ad5"} ' - ' @@ -1608,8 +1826,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" - function output?","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":"__get_timezone","item_id":"a52b36823d861ad5"} ' - ' @@ -1618,8 +1835,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" - Yes.","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":"`","item_id":"a52b36823d861ad5"} ' - ' @@ -1628,8 +1844,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":"\n- - Call","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + based on the","item_id":"a52b36823d861ad5"} ' - ' @@ -1639,7 +1855,7 @@ turns: ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" - no more tools","item_id":"8fda27ec54402330"} + schema provided","item_id":"a52b36823d861ad5"} ' - ' @@ -1648,8 +1864,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"? - Yes.","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" + in","item_id":"a52b36823d861ad5"} ' - ' @@ -1658,8 +1874,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":"\n- - Reply","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + the system prompt","item_id":"a52b36823d861ad5"} ' - ' @@ -1668,8 +1884,1454 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" - with exactly PARIS","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":", + but usually","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + I","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + just call `","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":"travel","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":".get","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"_timezone` + or","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":" + use","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":" + the full","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":" + name. Wait","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":", + looking","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" + at the system","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":" + prompt, the","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" + available tools are","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":":\n-","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":" + `get_exchange","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":"_rate`\n","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":"- + `search","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":"_hotels`","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":"\n- + `","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":"travel` + (","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":"namespace)\n","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":"And","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + the tool","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + description for","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":" + `get_timezone","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":"` + says `","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"agentic_ns","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":"__travel__","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":68,"output_index":0,"content_index":0,"delta":"get_timezone`","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":69,"output_index":0,"content_index":0,"delta":" + in the schema","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":70,"output_index":0,"content_index":0,"delta":".","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":71,"output_index":0,"content_index":0,"delta":"\nLet","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":72,"output_index":0,"content_index":0,"delta":"''s + check the","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":73,"output_index":0,"content_index":0,"delta":" + exact name to","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":74,"output_index":0,"content_index":0,"delta":" + use.","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":75,"output_index":0,"content_index":0,"delta":" + The prompt","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":76,"output_index":0,"content_index":0,"delta":" + says: `","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":77,"output_index":0,"content_index":0,"delta":"agentic_ns","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":78,"output_index":0,"content_index":0,"delta":"__travel__","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":79,"output_index":0,"content_index":0,"delta":"get_timezone`","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":80,"output_index":0,"content_index":0,"delta":" + is","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":81,"output_index":0,"content_index":0,"delta":" + the","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":82,"output_index":0,"content_index":0,"delta":" + function name in","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":83,"output_index":0,"content_index":0,"delta":" + the schema.","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":84,"output_index":0,"content_index":0,"delta":"\nParameters","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":85,"output_index":0,"content_index":0,"delta":": + `{\"","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":86,"output_index":0,"content_index":0,"delta":"city\": + \"","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":87,"output_index":0,"content_index":0,"delta":"Paris\"}`","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":88,"output_index":0,"content_index":0,"delta":".\nI","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":89,"output_index":0,"content_index":0,"delta":" + will call it","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":90,"output_index":0,"content_index":0,"delta":" + now.\n","item_id":"a52b36823d861ad5"} + + ' + - ' + + ' + - 'event: response.reasoning_text.done + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":91,"output_index":0,"content_index":0,"item_id":"a52b36823d861ad5","text":"The + user wants me to call the `get_timezone` function from the `travel` namespace, + which was loaded in the previous tool search result. The parameters should be + `{\"city\": \"Paris\"}`.\nI need to make sure I call exactly this tool and no + others.\nThe tool is `agentic_ns__travel__get_timezone` based on the schema + provided in the system prompt, but usually I just call `travel.get_timezone` + or use the full name. Wait, looking at the system prompt, the available tools + are:\n- `get_exchange_rate`\n- `search_hotels`\n- `travel` (namespace)\nAnd + the tool description for `get_timezone` says `agentic_ns__travel__get_timezone` + in the schema.\nLet''s check the exact name to use. The prompt says: `agentic_ns__travel__get_timezone` + is the function name in the schema.\nParameters: `{\"city\": \"Paris\"}`.\nI + will call it now.\n"} + + ' + - ' + + ' + - 'event: response.reasoning_part.done + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":92,"output_index":0,"content_index":0,"item_id":"a52b36823d861ad5","part":{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace, + which was loaded in the previous tool search result. The parameters should be + `{\"city\": \"Paris\"}`.\nI need to make sure I call exactly this tool and no + others.\nThe tool is `agentic_ns__travel__get_timezone` based on the schema + provided in the system prompt, but usually I just call `travel.get_timezone` + or use the full name. Wait, looking at the system prompt, the available tools + are:\n- `get_exchange_rate`\n- `search_hotels`\n- `travel` (namespace)\nAnd + the tool description for `get_timezone` says `agentic_ns__travel__get_timezone` + in the schema.\nLet''s check the exact name to use. The prompt says: `agentic_ns__travel__get_timezone` + is the function name in the schema.\nParameters: `{\"city\": \"Paris\"}`.\nI + will call it now.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":93,"output_index":0,"item":{"content":[{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace, + which was loaded in the previous tool search result. The parameters should be + `{\"city\": \"Paris\"}`.\nI need to make sure I call exactly this tool and no + others.\nThe tool is `agentic_ns__travel__get_timezone` based on the schema + provided in the system prompt, but usually I just call `travel.get_timezone` + or use the full name. Wait, looking at the system prompt, the available tools + are:\n- `get_exchange_rate`\n- `search_hotels`\n- `travel` (namespace)\nAnd + the tool description for `get_timezone` says `agentic_ns__travel__get_timezone` + in the schema.\nLet''s check the exact name to use. The prompt says: `agentic_ns__travel__get_timezone` + is the function name in the schema.\nParameters: `{\"city\": \"Paris\"}`.\nI + will call it now.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a52b36823d861ad5","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":94,"output_index":1,"item":{"arguments":"","call_id":"call_a84a81ee9e4ff247","caller":null,"id":"a38fa81e31f2efa8","name":"get_timezone","namespace":"travel","status":"in_progress","type":"function_call"}} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":95,"output_index":1,"delta":"{\"city\": + \"Paris","item_id":"a38fa81e31f2efa8"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","sequence_number":96,"output_index":1,"delta":"\"}","item_id":"a38fa81e31f2efa8"} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"type":"response.function_call_arguments.done","sequence_number":97,"output_index":1,"arguments":"{\"city\": + \"Paris\"}","item_id":"a38fa81e31f2efa8","name":"agentic_ns__travel__get_timezone"} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","sequence_number":98,"output_index":1,"item":{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_a84a81ee9e4ff247","caller":null,"id":"a38fa81e31f2efa8","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"}} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","sequence_number":99,"response":{"conversation_id":null,"created_at":1787715699,"error":null,"id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace, + which was loaded in the previous tool search result. The parameters should be + `{\"city\": \"Paris\"}`.\nI need to make sure I call exactly this tool and no + others.\nThe tool is `agentic_ns__travel__get_timezone` based on the schema + provided in the system prompt, but usually I just call `travel.get_timezone` + or use the full name. Wait, looking at the system prompt, the available tools + are:\n- `get_exchange_rate`\n- `search_hotels`\n- `travel` (namespace)\nAnd + the tool description for `get_timezone` says `agentic_ns__travel__get_timezone` + in the schema.\nLet''s check the exact name to use. The prompt says: `agentic_ns__travel__get_timezone` + is the function name in the schema.\nParameters: `{\"city\": \"Paris\"}`.\nI + will call it now.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a52b36823d861ad5","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_a84a81ee9e4ff247","id":"a38fa81e31f2efa8","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-b7b8-76d1-93ce-33557fafbad7","status":"completed","tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":802,"input_tokens_details":{"cached_tokens":0},"output_tokens":243,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1045}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 +- filename: t4 + request: + body: + input: + - call_id: call_a84a81ee9e4ff247 + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a03c28-bdd0-7c82-9e95-42037516c3de + store: true + stream: true + tool_choice: none + 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: + - 'event: response.created + + ' + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1787715699,"frequency_penalty":0.0,"id":"resp_01a03c28-c49d-7d72-9538-73724b7ad60f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1787715699,"frequency_penalty":0.0,"id":"resp_01a03c28-c49d-7d72-9538-73724b7ad60f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"be6b0d01ce538c64","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'event: response.reasoning_part.added + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"be6b0d01ce538c64","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user wants me","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + to combine","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + the information","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + from the","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + previous tool calls","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + and output a","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + specific string.","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":"\n\n","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":"1. + ","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":" + **Review","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":" + previous","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + tool outputs:**","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":"\n *","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" `get","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"_weather`","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" + for Paris","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" + returned: `","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"{\"city\":\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":"Paris\",\"condition","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":"\":\"clear\",\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":"temperature_c\":","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"21}`","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":".\n ","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" + * `","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":"travel","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":".get","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":"_timezone` + for","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" + Paris returned:","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":" + `{\"city","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":"\":\"Paris\",\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":"iana_timezone\":\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":"Europe/Paris","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":"\"}`.","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":"\n\n2.","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" **Instruction","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":":** + \"Use","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + both function outputs","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + and call no","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" + more tools.","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":" + Reply with exactly","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":" + PARIS_MIXED","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":"_TOOLS_OK","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":".\"\n\n3","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":". **","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":"Action:** + The","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" + specific","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":" + instruction regarding","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" + the content","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":" + (\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":"Use + both function","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":" + outputs\") seems","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":" + to be context","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":" + for the final","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":" + verification","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":", + but the","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":" + explicit output","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":" + instruction","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + is \"Reply","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + with exactly PARIS","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":"_MIXED_TO","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":"OLS_OK.\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"\n\n4.","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":" **Constraint","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":68,"output_index":0,"content_index":0,"delta":" + Check:** \"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":69,"output_index":0,"content_index":0,"delta":"call + no more","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":70,"output_index":0,"content_index":0,"delta":" + tools\"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":71,"output_index":0,"content_index":0,"delta":" + ->","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":72,"output_index":0,"content_index":0,"delta":" + I will","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":73,"output_index":0,"content_index":0,"delta":" + not call any","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":74,"output_index":0,"content_index":0,"delta":" + tool","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":75,"output_index":0,"content_index":0,"delta":". + \"","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":76,"output_index":0,"content_index":0,"delta":"Reply + with exactly","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":77,"output_index":0,"content_index":0,"delta":"...\" + -> I","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":78,"output_index":0,"content_index":0,"delta":" + will print","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":79,"output_index":0,"content_index":0,"delta":" + the","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":80,"output_index":0,"content_index":0,"delta":" + specific","item_id":"be6b0d01ce538c64"} + + ' + - ' + + ' + - 'event: response.reasoning_text.delta + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":81,"output_index":0,"content_index":0,"delta":" + text","item_id":"be6b0d01ce538c64"} ' - ' @@ -1678,7 +3340,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":"_WEATHER_OK","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":82,"output_index":0,"content_index":0,"delta":".\n\n5","item_id":"be6b0d01ce538c64"} ' - ' @@ -1687,8 +3349,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":"? - Yes.","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":83,"output_index":0,"content_index":0,"delta":". **","item_id":"be6b0d01ce538c64"} ' - ' @@ -1697,7 +3358,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":"\nProceed","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":84,"output_index":0,"content_index":0,"delta":"Final + Output:**","item_id":"be6b0d01ce538c64"} ' - ' @@ -1706,8 +3368,8 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":". - \nOutput","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":85,"output_index":0,"content_index":0,"delta":" + PARIS_MIXED","item_id":"be6b0d01ce538c64"} ' - ' @@ -1716,8 +3378,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":": - PARIS_WE","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":86,"output_index":0,"content_index":0,"delta":"_TOOLS_OK","item_id":"be6b0d01ce538c64"} ' - ' @@ -1726,7 +3387,7 @@ turns: - 'event: response.reasoning_text.delta ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":"ATHER_OK\n","item_id":"8fda27ec54402330"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":87,"output_index":0,"content_index":0,"delta":"\n","item_id":"be6b0d01ce538c64"} ' - ' @@ -1735,12 +3396,17 @@ turns: - 'event: response.reasoning_text.done ' - - 'data: {"type":"response.reasoning_text.done","sequence_number":47,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","text":"The - user wants me to use the function output and call no more tools.\nThen I need - to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature - 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking - constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- - Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n"} + - 'data: {"type":"response.reasoning_text.done","sequence_number":88,"output_index":0,"content_index":0,"item_id":"be6b0d01ce538c64","text":"The + user wants me to combine the information from the previous tool calls and output + a specific string.\n\n1. **Review previous tool outputs:**\n * `get_weather` + for Paris returned: `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\n * `travel.get_timezone` + for Paris returned: `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\n\n2. **Instruction:** + \"Use both function outputs and call no more tools. Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n3. **Action:** + The specific instruction regarding the content (\"Use both function outputs\") + seems to be context for the final verification, but the explicit output instruction + is \"Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n4. **Constraint Check:** + \"call no more tools\" -> I will not call any tool. \"Reply with exactly...\" + -> I will print the specific text.\n\n5. **Final Output:** PARIS_MIXED_TOOLS_OK\n"} ' - ' @@ -1749,12 +3415,17 @@ turns: - 'event: response.reasoning_part.done ' - - 'data: {"type":"response.reasoning_part.done","sequence_number":48,"output_index":0,"content_index":0,"item_id":"8fda27ec54402330","part":{"text":"The - user wants me to use the function output and call no more tools.\nThen I need - to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature - 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking - constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- - Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_part.done","sequence_number":89,"output_index":0,"content_index":0,"item_id":"be6b0d01ce538c64","part":{"text":"The + user wants me to combine the information from the previous tool calls and output + a specific string.\n\n1. **Review previous tool outputs:**\n * `get_weather` + for Paris returned: `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\n * `travel.get_timezone` + for Paris returned: `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\n\n2. **Instruction:** + \"Use both function outputs and call no more tools. Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n3. **Action:** + The specific instruction regarding the content (\"Use both function outputs\") + seems to be context for the final verification, but the explicit output instruction + is \"Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n4. **Constraint Check:** + \"call no more tools\" -> I will not call any tool. \"Reply with exactly...\" + -> I will print the specific text.\n\n5. **Final Output:** PARIS_MIXED_TOOLS_OK\n","type":"reasoning_text"}} ' - ' @@ -1763,12 +3434,17 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":49,"output_index":0,"item":{"content":[{"text":"The - user wants me to use the function output and call no more tools.\nThen I need - to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature - 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking - constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- - Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8fda27ec54402330","status":"completed","summary":[],"type":"reasoning"}} + - 'data: {"type":"response.output_item.done","sequence_number":90,"output_index":0,"item":{"content":[{"text":"The + user wants me to combine the information from the previous tool calls and output + a specific string.\n\n1. **Review previous tool outputs:**\n * `get_weather` + for Paris returned: `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\n * `travel.get_timezone` + for Paris returned: `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\n\n2. **Instruction:** + \"Use both function outputs and call no more tools. Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n3. **Action:** + The specific instruction regarding the content (\"Use both function outputs\") + seems to be context for the final verification, but the explicit output instruction + is \"Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n4. **Constraint Check:** + \"call no more tools\" -> I will not call any tool. \"Reply with exactly...\" + -> I will print the specific text.\n\n5. **Final Output:** PARIS_MIXED_TOOLS_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"be6b0d01ce538c64","status":"completed","summary":[],"type":"reasoning"}} ' - ' @@ -1777,7 +3453,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","sequence_number":50,"output_index":1,"item":{"content":[],"id":"ba47ead3ac614277","phase":null,"role":"assistant","status":"in_progress","type":"message"}} + - 'data: {"type":"response.output_item.added","sequence_number":91,"output_index":1,"item":{"content":[],"id":"bb25dfecba62be6b","phase":null,"role":"assistant","status":"in_progress","type":"message"}} ' - ' @@ -1786,7 +3462,7 @@ turns: - 'event: response.content_part.added ' - - 'data: {"type":"response.content_part.added","sequence_number":51,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} + - 'data: {"type":"response.content_part.added","sequence_number":92,"output_index":1,"content_index":0,"item_id":"bb25dfecba62be6b","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} ' - ' @@ -1795,7 +3471,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","sequence_number":52,"output_index":1,"content_index":0,"delta":"\n\nPAR","item_id":"ba47ead3ac614277","logprobs":[]} + - 'data: {"type":"response.output_text.delta","sequence_number":93,"output_index":1,"content_index":0,"delta":"\n\nPAR","item_id":"bb25dfecba62be6b","logprobs":[]} ' - ' @@ -1804,7 +3480,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","sequence_number":53,"output_index":1,"content_index":0,"delta":"IS_WEATHER","item_id":"ba47ead3ac614277","logprobs":[]} + - 'data: {"type":"response.output_text.delta","sequence_number":94,"output_index":1,"content_index":0,"delta":"IS_MIXED","item_id":"bb25dfecba62be6b","logprobs":[]} ' - ' @@ -1813,7 +3489,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","sequence_number":54,"output_index":1,"content_index":0,"delta":"_OK","item_id":"ba47ead3ac614277","logprobs":[]} + - 'data: {"type":"response.output_text.delta","sequence_number":95,"output_index":1,"content_index":0,"delta":"_TOOLS_OK","item_id":"bb25dfecba62be6b","logprobs":[]} ' - ' @@ -1822,7 +3498,7 @@ turns: - 'event: response.output_text.done ' - - 'data: {"type":"response.output_text.done","sequence_number":55,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","logprobs":[],"text":"\n\nPARIS_WEATHER_OK"} + - 'data: {"type":"response.output_text.done","sequence_number":96,"output_index":1,"content_index":0,"item_id":"bb25dfecba62be6b","logprobs":[],"text":"\n\nPARIS_MIXED_TOOLS_OK"} ' - ' @@ -1831,7 +3507,7 @@ turns: - 'event: response.content_part.done ' - - 'data: {"type":"response.content_part.done","sequence_number":56,"output_index":1,"content_index":0,"item_id":"ba47ead3ac614277","part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}} + - 'data: {"type":"response.content_part.done","sequence_number":97,"output_index":1,"content_index":0,"item_id":"bb25dfecba62be6b","part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}} ' - ' @@ -1840,7 +3516,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","sequence_number":57,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"ba47ead3ac614277","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} + - 'data: {"type":"response.output_item.done","sequence_number":98,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"bb25dfecba62be6b","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} ' - ' @@ -1849,12 +3525,20 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","sequence_number":58,"response":{"conversation_id":null,"created_at":1787143215,"error":null,"id":"resp_01a01a09-581f-78b3-bbbd-6cd5457d3b2c","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to use the function output and call no more tools.\nThen I need - to reply with exactly \"PARIS_WEATHER_OK\".\nI have the weather for Paris: temperature - 21°C, clear.\nI will just reply with \"PARIS_WEATHER_OK\".\nDone. \nChecking - constraints:\n- Use the function output? Yes.\n- Call no more tools? Yes.\n- - Reply with exactly PARIS_WEATHER_OK? Yes.\nProceed. \nOutput: PARIS_WEATHER_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8fda27ec54402330","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"ba47ead3ac614277","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-54b6-7171-8f20-ac680472ed04","status":"completed","usage":{"input_tokens":631,"input_tokens_details":{"cached_tokens":0},"output_tokens":115,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":746}}} + - 'data: {"type":"response.completed","sequence_number":99,"response":{"conversation_id":null,"created_at":1787715700,"error":null,"id":"resp_01a03c28-c49d-7d72-9538-73724b7ad60f","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to combine the information from the previous tool calls and output + a specific string.\n\n1. **Review previous tool outputs:**\n * `get_weather` + for Paris returned: `{\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}`.\n * `travel.get_timezone` + for Paris returned: `{\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}`.\n\n2. **Instruction:** + \"Use both function outputs and call no more tools. Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n3. **Action:** + The specific instruction regarding the content (\"Use both function outputs\") + seems to be context for the final verification, but the explicit output instruction + is \"Reply with exactly PARIS_MIXED_TOOLS_OK.\"\n\n4. **Constraint Check:** + \"call no more tools\" -> I will not call any tool. \"Reply with exactly...\" + -> I will print the specific text.\n\n5. **Final Output:** PARIS_MIXED_TOOLS_OK\n","type":"reasoning_text"}],"encrypted_content":null,"id":"be6b0d01ce538c64","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"bb25dfecba62be6b","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a03c28-bdd0-7c82-9e95-42037516c3de","status":"completed","tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":347,"input_tokens_details":{"cached_tokens":0},"output_tokens":219,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":566}}} ' - ' diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml index 300ce809..e8ef7e80 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml @@ -2,29 +2,29 @@ turns: - filename: t1 request: body: - input: First call tool_search exactly once to find a weather tool. Do not call - get_weather yet. + input: First call tool_search exactly once to find both a current-weather function + and a travel time-zone function. Do not call any other tool yet. max_output_tokens: 4096 model: Qwen/Qwen3.6-35B-A3B-FP8 parallel_tool_calls: false store: true - tool_choice: auto + tool_choice: required tools: - - description: Search the client tool catalog for a tool that can satisfy the + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -36,6 +36,81 @@ turns: type: object strict: true type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace type: response.create headers: {} method: WEBSOCKET @@ -48,1176 +123,5051 @@ turns: sse: - 'event: response.created - data: {"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + data: {"response":{"background":false,"created_at":1787715702,"frequency_penalty":0.0,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} ' - 'event: response.in_progress - data: {"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + data: {"response":{"background":false,"created_at":1787715702,"frequency_penalty":0.0,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} ' - 'event: response.output_item.added - data: {"item":{"content":null,"encrypted_content":null,"id":"952e282906b2213b","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + data: {"item":{"content":null,"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} ' - 'event: response.reasoning_part.added - data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + data: {"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" user wants me","item_id":"952e282906b2213b","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" user wants me","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" to call","item_id":"952e282906b2213b","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" to find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the","item_id":"952e282906b2213b","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" two","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `tool_search","item_id":"952e282906b2213b","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" specific tools using","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"` function exactly","item_id":"952e282906b2213b","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" once.\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"tool_search`:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"The description","item_id":"952e282906b2213b","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\n1.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" of","item_id":"952e282906b2213b","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" A current-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the request","item_id":"952e282906b2213b","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"weather function\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" is \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"2. A","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" travel time-zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" tool\".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" function\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"I need","item_id":"952e282906b2213b","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" need to call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" to construct","item_id":"952e282906b2213b","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" a","item_id":"952e282906b2213b","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"` exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" query","item_id":"952e282906b2213b","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":".","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" that matches this","item_id":"952e282906b2213b","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":".","item_id":"952e282906b2213b","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" should construct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\nThe available","item_id":"952e282906b2213b","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" a query that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" tool is `","item_id":"952e282906b2213b","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" captures","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"get_weather`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\nI will","item_id":"952e282906b2213b","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" description","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" call `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" of these","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"_search` with","item_id":"952e282906b2213b","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" capabilities.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the query \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" available tools listed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" tool\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" in the system","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" or similar.","item_id":"952e282906b2213b","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" prompt are:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"get_weather`,","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" user explicitly said","item_id":"952e282906b2213b","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `get_exchange","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" \"Do not","item_id":"952e282906b2213b","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"_rate`, `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" call get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"search_hotels","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" yet.\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"`, `travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n\nParameters","item_id":"952e282906b2213b","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"`.\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" for `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"get_weather`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"_search`:\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" seems to match","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"query","item_id":"952e282906b2213b","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":": \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"current-weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" function\".\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\"\n\nPlan","item_id":"952e282906b2213b","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"`travel`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":":\n1","item_id":"952e282906b2213b","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" seems to match","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":". Call `","item_id":"952e282906b2213b","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" \"travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"tool_search`","item_id":"952e282906b2213b","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"-zone function\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" with the","item_id":"952e282906b2213b","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" (or perhaps","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" query \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" should","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\".\n2","item_id":"952e282906b2213b","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" search for \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":". Wait for","item_id":"952e282906b2213b","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the result.","item_id":"952e282906b2213b","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n3.","item_id":"952e282906b2213b","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\" specifically","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" Do not call","item_id":"952e282906b2213b","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":").\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"Let''s formulate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n\nLet''s","item_id":"952e282906b2213b","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" query that searches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" execute the call","item_id":"952e282906b2213b","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" for both or","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_text.done + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"sequence_number":62,"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"response.reasoning_text.done"} + data: {"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_part.done + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"reasoning_text"},"sequence_number":63,"type":"response.reasoning_part.done"} + data: {"content_index":0,"delta":" tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta - data: {"item":{"content":[{"text":"The user wants me to call the `tool_search` - function exactly once.\nThe description of the request is \"find a weather tool\".\nI - need to construct a query that matches this.\nThe available tool is `get_weather`.\nI - will call `tool_search` with the query \"find a weather tool\" or similar.\nThe - user explicitly said \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: - \"find a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find - a weather tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s - execute the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":64,"type":"response.output_item.done"} + data: {"content_index":0,"delta":" to return","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta - data: {"item":{"arguments":{},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":65,"type":"response.output_item.added"} + data: {"content_index":0,"delta":" results","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta - data: {"item":{"arguments":{"query":"find a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":66,"type":"response.output_item.done"} + data: {"content_index":0,"delta":" for both types","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} ' - - 'event: response.completed + - 'event: response.reasoning_text.delta - data: {"response":{"conversation_id":null,"created_at":1787143218,"error":null,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"find - a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":174,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":531}},"sequence_number":67,"type":"response.completed"} + data: {"content_index":0,"delta":" of capabilities.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} ' - - 'data: [DONE] + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} ' - status_code: 101 - websocket: - - '{"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' - - '{"response":{"background":false,"created_at":1787143217,"frequency_penalty":0.0,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' - - '{"item":{"content":null,"encrypted_content":null,"id":"952e282906b2213b","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' - - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' - - '{"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" user wants me","item_id":"952e282906b2213b","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" to call","item_id":"952e282906b2213b","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the","item_id":"952e282906b2213b","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `tool_search","item_id":"952e282906b2213b","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"` function exactly","item_id":"952e282906b2213b","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" once.\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"The description","item_id":"952e282906b2213b","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" of","item_id":"952e282906b2213b","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the request","item_id":"952e282906b2213b","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" is \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" tool\".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"I need","item_id":"952e282906b2213b","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" to construct","item_id":"952e282906b2213b","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" a","item_id":"952e282906b2213b","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" query","item_id":"952e282906b2213b","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" that matches this","item_id":"952e282906b2213b","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":".","item_id":"952e282906b2213b","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\nThe available","item_id":"952e282906b2213b","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" tool is `","item_id":"952e282906b2213b","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"get_weather`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\nI will","item_id":"952e282906b2213b","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" call `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"_search` with","item_id":"952e282906b2213b","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the query \"","item_id":"952e282906b2213b","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"find a weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" tool\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" or similar.","item_id":"952e282906b2213b","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"The","item_id":"952e282906b2213b","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" user explicitly said","item_id":"952e282906b2213b","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" \"Do not","item_id":"952e282906b2213b","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" call get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" yet.\"","item_id":"952e282906b2213b","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n\nParameters","item_id":"952e282906b2213b","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" for `tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"_search`:\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"query","item_id":"952e282906b2213b","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":": \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\"\n\nPlan","item_id":"952e282906b2213b","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":":\n1","item_id":"952e282906b2213b","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":". Call `","item_id":"952e282906b2213b","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"tool_search`","item_id":"952e282906b2213b","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" with the","item_id":"952e282906b2213b","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" query \"find","item_id":"952e282906b2213b","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" a weather tool","item_id":"952e282906b2213b","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\".\n2","item_id":"952e282906b2213b","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":". Wait for","item_id":"952e282906b2213b","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the result.","item_id":"952e282906b2213b","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n3.","item_id":"952e282906b2213b","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" Do not call","item_id":"952e282906b2213b","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `get_weather","item_id":"952e282906b2213b","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"`.","item_id":"952e282906b2213b","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n\nLet''s","item_id":"952e282906b2213b","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" execute the call","item_id":"952e282906b2213b","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":".\n","item_id":"952e282906b2213b","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"sequence_number":62,"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"response.reasoning_text.done"}' - - '{"content_index":0,"item_id":"952e282906b2213b","output_index":0,"part":{"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"reasoning_text"},"sequence_number":63,"type":"response.reasoning_part.done"}' - - '{"item":{"content":[{"text":"The user wants me to call the `tool_search` function - exactly once.\nThe description of the request is \"find a weather tool\".\nI - need to construct a query that matches this.\nThe available tool is `get_weather`.\nI - will call `tool_search` with the query \"find a weather tool\" or similar.\nThe - user explicitly said \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: - \"find a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find - a weather tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s - execute the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":64,"type":"response.output_item.done"}' - - '{"item":{"arguments":{},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":65,"type":"response.output_item.added"}' - - '{"item":{"arguments":{"query":"find a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":66,"type":"response.output_item.done"}' - - '{"response":{"conversation_id":null,"created_at":1787143218,"error":null,"id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call the `tool_search` function exactly once.\nThe description - of the request is \"find a weather tool\".\nI need to construct a query that - matches this.\nThe available tool is `get_weather`.\nI will call `tool_search` - with the query \"find a weather tool\" or similar.\nThe user explicitly said - \"Do not call get_weather yet.\"\n\nParameters for `tool_search`:\nquery: \"find - a weather tool\"\n\nPlan:\n1. Call `tool_search` with the query \"find a weather - tool\".\n2. Wait for the result.\n3. Do not call `get_weather`.\n\nLet''s execute - the call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"952e282906b2213b","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"find - a weather tool"},"call_id":"call_97fc0ce62a91df3b","execution":"client","id":"tsc_578501e4c22f2bed","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":357,"input_tokens_details":{"cached_tokens":0},"output_tokens":174,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":531}},"sequence_number":67,"type":"response.completed"}' -- filename: t2 - request: - body: - input: - - call_id: call_97fc0ce62a91df3b - execution: client - status: completed - tools: - - defer_loading: true - description: Get the current weather for a city. - name: get_weather - parameters: - additionalProperties: false - properties: - city: - type: string - required: - - city - type: object - strict: true - type: function - type: tool_search_output - - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. - role: user - type: message - max_output_tokens: 4096 - model: Qwen/Qwen3.6-35B-A3B-FP8 - parallel_tool_calls: false - previous_response_id: resp_01a01a09-61c7-7421-92a8-072919c4fe24 - store: true - tool_choice: auto - type: response.create - headers: {} - method: WEBSOCKET - path: /v1/responses - query_params: {} - transport: websocket - response: - headers: - transport: websocket - sse: - - 'event: response.created + - 'event: response.reasoning_text.delta - data: {"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + data: {"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} ' - - 'event: response.in_progress + - 'event: response.reasoning_text.delta - data: {"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + data: {"content_index":0,"delta":"Search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta - data: {"item":{"content":null,"encrypted_content":null,"id":"8ca518951f0614c8","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + data: {"content_index":0,"delta":" the client tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_part.added + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + data: {"content_index":0,"delta":" catalog for tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"The","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" that can satisfy","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" user wants me","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" the request.\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" to call the","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"Query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"` tool with","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" idea","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the parameter","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":": \"tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `{\"city","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" for current weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\": \"Paris","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" and travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\"}`.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" zones\"\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\nI have","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"Let''s check","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" already performed","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" the descriptions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" in","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"tool_search`","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" the prompt:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" in the previous","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\n`get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" turn.\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"_weather` \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"I must call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" Get the current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" weather for a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"` exactly once","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" city\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":".\nI","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"travel` \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" must not call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" Travel location tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `tool_search","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":".\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"` again.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" will construct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"Tool","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"`\nParameters","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" for current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":": `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"{\"city\":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" \"Paris\"","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" tools.\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"}`\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"Query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_text.done + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"sequence_number":35,"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"response.reasoning_text.done"} + data: {"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_part.done + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"},"sequence_number":36,"type":"response.reasoning_part.done"} + data: {"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta - data: {"item":{"content":[{"text":"The user wants me to call the `get_weather` - tool with the parameter `{\"city\": \"Paris\"}`.\nI have already performed the - `tool_search` in the previous turn.\nI must call `get_weather` exactly once.\nI - must not call `tool_search` again.\n\nTool: `get_weather`\nParameters: `{\"city\": - \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":37,"type":"response.output_item.done"} + data: {"content_index":0,"delta":" time zone tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta - data: {"item":{"arguments":"","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":38,"type":"response.output_item.added"} + data: {"content_index":0,"delta":"\"\n\nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta - data: {"delta":"{\"city\": \"","item_id":"a96229fa504fb524","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.delta"} + data: {"content_index":0,"delta":", the prompt","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta - data: {"delta":"Paris","item_id":"a96229fa504fb524","output_index":1,"sequence_number":40,"type":"response.function_call_arguments.delta"} + data: {"content_index":0,"delta":" says \"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} ' - - 'event: response.function_call_arguments.delta + - 'event: response.reasoning_text.delta - data: {"delta":"\"}","item_id":"a96229fa504fb524","output_index":1,"sequence_number":41,"type":"response.function_call_arguments.delta"} + data: {"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} ' - - 'event: response.function_call_arguments.done + - 'event: response.reasoning_text.delta - data: {"arguments":"{\"city\": \"Paris\"}","item_id":"a96229fa504fb524","name":"get_weather","output_index":1,"sequence_number":42,"type":"response.function_call_arguments.done"} + data: {"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.done + - 'event: response.reasoning_text.delta - data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":43,"type":"response.output_item.done"} + data: {"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} ' - - 'event: response.completed + - 'event: response.reasoning_text.delta - data: {"response":{"conversation_id":null,"created_at":1787143219,"error":null,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": - \"Paris\"}","call_id":"call_a7b21478da87605a","id":"a96229fa504fb524","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","status":"completed","usage":{"input_tokens":557,"input_tokens_details":{"cached_tokens":0},"output_tokens":108,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":665}},"sequence_number":44,"type":"response.completed"} + data: {"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} ' - - 'data: [DONE] + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} ' - status_code: 101 - websocket: - - '{"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' - - '{"response":{"background":false,"created_at":1787143218,"frequency_penalty":0.0,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' - - '{"item":{"content":null,"encrypted_content":null,"id":"8ca518951f0614c8","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' - - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' - - '{"content_index":0,"delta":"The","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" user wants me","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" to call the","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"` tool with","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the parameter","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `{\"city","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\": \"Paris","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\"}`.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\nI have","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" already performed","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"tool_search`","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" in the previous","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" turn.\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"I must call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"` exactly once","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":".\nI","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" must not call","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `tool_search","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"` again.","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"Tool","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" `get_weather","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"`\nParameters","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":": `","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"{\"city\":","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" \"Paris\"","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"}`\n","item_id":"8ca518951f0614c8","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"sequence_number":35,"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"response.reasoning_text.done"}' - - '{"content_index":0,"item_id":"8ca518951f0614c8","output_index":0,"part":{"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"},"sequence_number":36,"type":"response.reasoning_part.done"}' - - '{"item":{"content":[{"text":"The user wants me to call the `get_weather` tool - with the parameter `{\"city\": \"Paris\"}`.\nI have already performed the `tool_search` - in the previous turn.\nI must call `get_weather` exactly once.\nI must not call - `tool_search` again.\n\nTool: `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":37,"type":"response.output_item.done"}' - - '{"item":{"arguments":"","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":38,"type":"response.output_item.added"}' - - '{"delta":"{\"city\": \"","item_id":"a96229fa504fb524","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.delta"}' - - '{"delta":"Paris","item_id":"a96229fa504fb524","output_index":1,"sequence_number":40,"type":"response.function_call_arguments.delta"}' - - '{"delta":"\"}","item_id":"a96229fa504fb524","output_index":1,"sequence_number":41,"type":"response.function_call_arguments.delta"}' - - '{"arguments":"{\"city\": \"Paris\"}","item_id":"a96229fa504fb524","name":"get_weather","output_index":1,"sequence_number":42,"type":"response.function_call_arguments.done"}' - - '{"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_a7b21478da87605a","caller":null,"id":"a96229fa504fb524","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":43,"type":"response.output_item.done"}' - - '{"response":{"conversation_id":null,"created_at":1787143219,"error":null,"id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to call the `get_weather` tool with the parameter `{\"city\": - \"Paris\"}`.\nI have already performed the `tool_search` in the previous turn.\nI - must call `get_weather` exactly once.\nI must not call `tool_search` again.\n\nTool: - `get_weather`\nParameters: `{\"city\": \"Paris\"}`\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8ca518951f0614c8","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": - \"Paris\"}","call_id":"call_a7b21478da87605a","id":"a96229fa504fb524","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a01a09-61c7-7421-92a8-072919c4fe24","status":"completed","usage":{"input_tokens":557,"input_tokens_details":{"cached_tokens":0},"output_tokens":108,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":665}},"sequence_number":44,"type":"response.completed"}' -- filename: t3 - request: - body: - input: - - call_id: call_a7b21478da87605a - output: '{"city":"Paris","condition":"clear","temperature_c":21}' - type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. - role: user - type: message - max_output_tokens: 4096 - model: Qwen/Qwen3.6-35B-A3B-FP8 - parallel_tool_calls: false - previous_response_id: resp_01a01a09-660f-7b42-97dc-8390896b01d7 - store: true - tool_choice: auto - type: response.create - headers: {} - method: WEBSOCKET - path: /v1/responses - query_params: {} - transport: websocket - response: - headers: - transport: websocket - sse: - - 'event: response.created + - 'event: response.reasoning_text.delta - data: {"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + data: {"content_index":0,"delta":" will call `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} ' - - 'event: response.in_progress + - 'event: response.reasoning_text.delta - data: {"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + data: {"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} ' - - 'event: response.output_item.added + - 'event: response.reasoning_text.delta - data: {"item":{"content":null,"encrypted_content":null,"id":"a82a3a2c33325074","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + data: {"content_index":0,"delta":" with this","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} ' - - 'event: response.reasoning_part.added + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" query.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" must","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" ensure I only","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call it once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Parameters","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":":\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" time zone tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"\n\nLet","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''s do","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" it. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Actually","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":", the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" description","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for `travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` says","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Travel location","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tools.\" It","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" might not","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":140,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" explicitly say","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":141,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" time zone,","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":142,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" but I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":143,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''ll search for","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":144,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" it. The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":145,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user''s","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":146,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" instruction is to","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":147,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":148,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":149,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":150,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to find them","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":151,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":152,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" will proceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":153,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nCall","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":154,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": `tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":155,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_search(query=\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":156,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":157,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":158,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tools\")`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":159,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nCheck","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":160,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" constraints: \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":161,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"First call tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":162,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_search exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":163,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"... Do not","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":164,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call any other","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":165,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool yet.\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":166,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" -> S","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":167,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"atisfied.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":168,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Proceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":169,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nOutput","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":170,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" matches schema","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":171,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":172,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":173,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":", let''s","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":174,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" look","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":175,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" at the exact","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":176,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" wording","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":177,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":178,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":179,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":180,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":181,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":182,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":183,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''ll use","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":184,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":185,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" ph","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":186,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"rasing in","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":187,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the query.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":188,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":189,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search(query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":190,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"=\"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":191,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a current-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":192,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"weather function and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":193,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":194,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-zone function\")","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":195,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`\nThis","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":196,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" should","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":197,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" work.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":198,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Let''s generate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":199,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":200,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":201,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nAll","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":202,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" good","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":203,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":204,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ing. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":205,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"[Self-C","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":206,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"orrection/","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":207,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Verification during thought","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":208,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"]\nThe","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":209,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" prompt says:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":210,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Search the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":211,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" client tool catalog","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":212,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for tools that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":213,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" can satisfy the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":214,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" request. Available","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":215,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" catalog entries:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":216,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" get_weather \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":217,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Get the current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":218,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather for a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":219,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" city; get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":220,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_exchange_rate \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":221,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Get the exchange","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":222,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" rate between two","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":223,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" currencies; search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":224,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_hotels \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":225,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Search for hotels","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":226,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in a city","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":227,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"; travel \u2014","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":228,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Travel location tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":229,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\"\nSo","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":230,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":231,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_weather` and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":232,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `travel`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":233,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" are the ones","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":234,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":235,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" will call `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":236,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":237,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" with a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":238,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":239,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that matches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":240,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" these.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":241,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":242,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":243,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"current weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":244,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":245,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" zone tools\"`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":246,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nReady.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":247,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \nOutput matches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":248,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" expected","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":249,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" format.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":250,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Proceeds.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":251,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \n[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":252,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Generation] ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":253,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" calls tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":254,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n(Self","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":255,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-Correction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":256,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"/Refinement","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":257,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" during thought)","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":258,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nThe","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":259,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":260,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":261,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":262,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":263,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":264,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":265,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":266,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''ll","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":267,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" just put","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":268,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that exactly","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":269,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in the query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":270,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":271,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":272,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":273,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":274,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" time zone functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":275,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"`\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":276,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":277,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"s. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":278,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":279,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Generation","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":280,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"]\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":281,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"```","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":282,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"json\n{\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":283,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query\": \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":284,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":285,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":286,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" functions\"}\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":287,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"```\nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":288,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":", the tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":289,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" definition","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":290,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" expects","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":291,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `query`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":292,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" as a string","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":293,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":294,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" will generate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":295,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the tool call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":296,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" now. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":297,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"All steps verified","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":298,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":299,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"s. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":300,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"[Final Check","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":301,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"]\n-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":302,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":303,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":304,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":305,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" exactly once?","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":306,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Yes.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":307,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"- Find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":308,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" both functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":309,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"? The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":310,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" query asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":311,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for them","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":312,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":313,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Do not call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":314,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" any other tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":315,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"?","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":316,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Yes.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":317,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":318,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Parameters","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":319,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" correct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":320,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"? Yes","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":321,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":322,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nOutput","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":323,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" matches. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":324,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Proceeds.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":325,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \n[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":326,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Generation] (","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":327,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Internal","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":328,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" thought","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":329,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" process","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":330,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" complete","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":331,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":")\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":332,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":333,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" with query \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":334,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":335,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":336,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":337,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":338,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n(Self","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":339,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-Correction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":340,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"/Note:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":341,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I should just","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":342,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" follow","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":343,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the instruction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":344,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" exactly.)\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":345,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Calling","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":346,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":347,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \n[Done","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":348,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"] \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":349,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`{\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":350,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"query\": \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":351,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":352,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":353,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" functions\"}`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":354,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":355,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" This","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":356,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is what","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":357,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I will output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":358,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":359,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Wait, the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":360,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":361,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"First","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":362,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":363,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" exactly once to","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":364,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" find both a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":365,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" current-weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":366,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" function and a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":367,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" travel time-zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":368,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" function.\"\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":369,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"I will just","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":370,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":371,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" it.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":372,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Done. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":373,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Proceeds","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":374,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n[","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":375,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Output Generation]","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":376,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":377,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" calls","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":378,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool_search.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":379,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":380,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":381,"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` \u2014 Get the current weather for + a city\n`travel` \u2014 Travel location tools.\n\nI will construct a query that + asks for current weather and travel time zone tools.\n\nQuery: \"current weather + and travel time zone tools\"\n\nWait, the prompt says \"find both a current-weather + function and a travel time-zone function.\"\nI will call `tool_search` with + this query.\nI must ensure I only call it once.\n\nParameters:\nquery: \"current + weather and travel time zone tools\"\n\nLet''s do it. \nActually, the description + for `travel` says \"Travel location tools.\" It might not explicitly say time + zone, but I''ll search for it. The user''s instruction is to call `tool_search` + exactly once to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current + weather and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather \u2014 Get the current weather + for a city; get_exchange_rate \u2014 Get the exchange rate between two currencies; + search_hotels \u2014 Search for hotels in a city; travel \u2014 Travel location + tools.\"\nSo `get_weather` and `travel` are the ones.\nI will call `tool_search` + with a query that matches these.\n`query: \"current weather and travel time + zone tools\"`\nReady. \nOutput matches expected format.\nProceeds. \n[Output + Generation] -> calls tool_search. \n(Self-Correction/Refinement during thought)\nThe + prompt says \"find both a current-weather function and a travel time-zone function.\"\nI''ll + just put that exactly in the query.\n`query: \"current weather and travel time + zone functions\"`\nDone. \nProceeds. \n[Output Generation]\n```json\n{\"query\": + \"current weather and travel time zone functions\"}\n```\nWait, the tool definition + expects `query` as a string.\nI will generate the tool call now. \nAll steps + verified. \nProceeds. \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- + Find both functions? The query asks for them.\n- Do not call any other tool? + Yes.\n- Parameters correct? Yes.\nDone. \nOutput matches. \nProceeds. \n[Output + Generation] (Internal thought process complete)\n`tool_search` with query \"current + weather and travel time zone functions\"\nDone. \n(Self-Correction/Note: I should + just follow the instruction exactly.)\nCalling tool. \n[Done] \n`{\"query\": + \"current weather and travel time zone functions\"}` -> This is what I will + output. \nWait, the prompt says \"First call tool_search exactly once to find + both a current-weather function and a travel time-zone function.\"\nI will just + call it.\nDone. \nProceeds. \n[Output Generation] -> calls tool_search.\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"part":{"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` \u2014 Get the current weather for + a city\n`travel` \u2014 Travel location tools.\n\nI will construct a query that + asks for current weather and travel time zone tools.\n\nQuery: \"current weather + and travel time zone tools\"\n\nWait, the prompt says \"find both a current-weather + function and a travel time-zone function.\"\nI will call `tool_search` with + this query.\nI must ensure I only call it once.\n\nParameters:\nquery: \"current + weather and travel time zone tools\"\n\nLet''s do it. \nActually, the description + for `travel` says \"Travel location tools.\" It might not explicitly say time + zone, but I''ll search for it. The user''s instruction is to call `tool_search` + exactly once to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current + weather and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather \u2014 Get the current weather + for a city; get_exchange_rate \u2014 Get the exchange rate between two currencies; + search_hotels \u2014 Search for hotels in a city; travel \u2014 Travel location + tools.\"\nSo `get_weather` and `travel` are the ones.\nI will call `tool_search` + with a query that matches these.\n`query: \"current weather and travel time + zone tools\"`\nReady. \nOutput matches expected format.\nProceeds. \n[Output + Generation] -> calls tool_search. \n(Self-Correction/Refinement during thought)\nThe + prompt says \"find both a current-weather function and a travel time-zone function.\"\nI''ll + just put that exactly in the query.\n`query: \"current weather and travel time + zone functions\"`\nDone. \nProceeds. \n[Output Generation]\n```json\n{\"query\": + \"current weather and travel time zone functions\"}\n```\nWait, the tool definition + expects `query` as a string.\nI will generate the tool call now. \nAll steps + verified. \nProceeds. \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- + Find both functions? The query asks for them.\n- Do not call any other tool? + Yes.\n- Parameters correct? Yes.\nDone. \nOutput matches. \nProceeds. \n[Output + Generation] (Internal thought process complete)\n`tool_search` with query \"current + weather and travel time zone functions\"\nDone. \n(Self-Correction/Note: I should + just follow the instruction exactly.)\nCalling tool. \n[Done] \n`{\"query\": + \"current weather and travel time zone functions\"}` -> This is what I will + output. \nWait, the prompt says \"First call tool_search exactly once to find + both a current-weather function and a travel time-zone function.\"\nI will just + call it.\nDone. \nProceeds. \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"},"sequence_number":382,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to find two specific tools + using `tool_search`:\n1. A current-weather function\n2. A travel time-zone function\n\nI + need to call `tool_search` exactly once. I should construct a query that captures + the description of these capabilities.\nThe available tools listed in the system + prompt are: `get_weather`, `get_exchange_rate`, `search_hotels`, `travel`.\n`get_weather` + seems to match \"current-weather function\".\n`travel` seems to match \"travel + time-zone function\" (or perhaps I should search for \"time zone\" specifically).\n\nLet''s + formulate a query that searches for both or asks the tool to return results + for both types of capabilities. The prompt says \"Search the client tool catalog + for tools that can satisfy the request.\"\n\nQuery idea: \"tools for current + weather and travel time zones\"\n\nLet''s check the descriptions in the prompt:\n`get_weather` + \u2014 Get the current weather for a city\n`travel` \u2014 Travel location tools.\n\nI + will construct a query that asks for current weather and travel time zone tools.\n\nQuery: + \"current weather and travel time zone tools\"\n\nWait, the prompt says \"find + both a current-weather function and a travel time-zone function.\"\nI will call + `tool_search` with this query.\nI must ensure I only call it once.\n\nParameters:\nquery: + \"current weather and travel time zone tools\"\n\nLet''s do it. \nActually, + the description for `travel` says \"Travel location tools.\" It might not explicitly + say time zone, but I''ll search for it. The user''s instruction is to call `tool_search` + exactly once to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current + weather and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather \u2014 Get the current weather + for a city; get_exchange_rate \u2014 Get the exchange rate between two currencies; + search_hotels \u2014 Search for hotels in a city; travel \u2014 Travel location + tools.\"\nSo `get_weather` and `travel` are the ones.\nI will call `tool_search` + with a query that matches these.\n`query: \"current weather and travel time + zone tools\"`\nReady. \nOutput matches expected format.\nProceeds. \n[Output + Generation] -> calls tool_search. \n(Self-Correction/Refinement during thought)\nThe + prompt says \"find both a current-weather function and a travel time-zone function.\"\nI''ll + just put that exactly in the query.\n`query: \"current weather and travel time + zone functions\"`\nDone. \nProceeds. \n[Output Generation]\n```json\n{\"query\": + \"current weather and travel time zone functions\"}\n```\nWait, the tool definition + expects `query` as a string.\nI will generate the tool call now. \nAll steps + verified. \nProceeds. \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- + Find both functions? The query asks for them.\n- Do not call any other tool? + Yes.\n- Parameters correct? Yes.\nDone. \nOutput matches. \nProceeds. \n[Output + Generation] (Internal thought process complete)\n`tool_search` with query \"current + weather and travel time zone functions\"\nDone. \n(Self-Correction/Note: I should + just follow the instruction exactly.)\nCalling tool. \n[Done] \n`{\"query\": + \"current weather and travel time zone functions\"}` -> This is what I will + output. \nWait, the prompt says \"First call tool_search exactly once to find + both a current-weather function and a travel time-zone function.\"\nI will just + call it.\nDone. \nProceeds. \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":383,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"arguments":{},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":384,"type":"response.output_item.added"} + + ' + - 'event: response.output_item.done + + data: {"item":{"arguments":{"query":"current weather and travel time zone functions"},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":385,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787715707,"error":null,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` \u2014 Get the current weather for + a city\n`travel` \u2014 Travel location tools.\n\nI will construct a query that + asks for current weather and travel time zone tools.\n\nQuery: \"current weather + and travel time zone tools\"\n\nWait, the prompt says \"find both a current-weather + function and a travel time-zone function.\"\nI will call `tool_search` with + this query.\nI must ensure I only call it once.\n\nParameters:\nquery: \"current + weather and travel time zone tools\"\n\nLet''s do it. \nActually, the description + for `travel` says \"Travel location tools.\" It might not explicitly say time + zone, but I''ll search for it. The user''s instruction is to call `tool_search` + exactly once to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current + weather and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather \u2014 Get the current weather + for a city; get_exchange_rate \u2014 Get the exchange rate between two currencies; + search_hotels \u2014 Search for hotels in a city; travel \u2014 Travel location + tools.\"\nSo `get_weather` and `travel` are the ones.\nI will call `tool_search` + with a query that matches these.\n`query: \"current weather and travel time + zone tools\"`\nReady. \nOutput matches expected format.\nProceeds. \n[Output + Generation] -> calls tool_search. \n(Self-Correction/Refinement during thought)\nThe + prompt says \"find both a current-weather function and a travel time-zone function.\"\nI''ll + just put that exactly in the query.\n`query: \"current weather and travel time + zone functions\"`\nDone. \nProceeds. \n[Output Generation]\n```json\n{\"query\": + \"current weather and travel time zone functions\"}\n```\nWait, the tool definition + expects `query` as a string.\nI will generate the tool call now. \nAll steps + verified. \nProceeds. \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- + Find both functions? The query asks for them.\n- Do not call any other tool? + Yes.\n- Parameters correct? Yes.\nDone. \nOutput matches. \nProceeds. \n[Output + Generation] (Internal thought process complete)\n`tool_search` with query \"current + weather and travel time zone functions\"\nDone. \n(Self-Correction/Note: I should + just follow the instruction exactly.)\nCalling tool. \n[Done] \n`{\"query\": + \"current weather and travel time zone functions\"}` -> This is what I will + output. \nWait, the prompt says \"First call tool_search exactly once to find + both a current-weather function and a travel time-zone function.\"\nI will just + call it.\nDone. \nProceeds. \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"current + weather and travel time zone functions"},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","tool_choice":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":394,"input_tokens_details":{"cached_tokens":0},"output_tokens":963,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1357}},"sequence_number":386,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787715702,"frequency_penalty":0.0,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787715702,"frequency_penalty":0.0,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"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":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" two","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" specific tools using","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n1.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" A current-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"weather function\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"2. A","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time-zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" function\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" need to call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" should construct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a query that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" captures","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" description","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" of these","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" capabilities.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" available tools listed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the system","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt are:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`,","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_exchange","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_rate`, `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"search_hotels","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`, `travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" seems to match","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"current-weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" function\".\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`travel`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" seems to match","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-zone function\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" (or perhaps","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" should","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" search for \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\" specifically","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":").\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Let''s formulate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query that searches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for both or","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to return","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" results","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for both types","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" of capabilities.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the client tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" catalog for tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that can satisfy","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the request.\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" idea","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for current weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" zones\"\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Let''s check","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the descriptions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the prompt:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n`get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_weather` —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Get the current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather for a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" city\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel` —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Travel location tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will construct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tools.\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time zone tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"\n\nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":", the prompt","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" says \"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will call `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with this","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" must","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" ensure I only","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call it once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Parameters","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":":\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time zone tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"\n\nLet","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''s do","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" it. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Actually","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":", the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" description","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for `travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` says","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Travel location","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tools.\" It","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" might not","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":140,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" explicitly say","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":141,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time zone,","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":142,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" but I","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":143,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''ll search for","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":144,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" it. The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":145,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user''s","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":146,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" instruction is to","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":147,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":148,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":149,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":150,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to find them","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":151,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":152,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will proceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":153,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nCall","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":154,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": `tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":155,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_search(query=\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":156,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":157,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":158,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tools\")`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":159,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nCheck","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":160,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" constraints: \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":161,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"First call tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":162,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_search exactly once","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":163,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"... Do not","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":164,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call any other","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":165,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool yet.\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":166,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" -> S","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":167,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"atisfied.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":168,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Proceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":169,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nOutput","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":170,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" matches schema","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":171,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":172,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":173,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":", let''s","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":174,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" look","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":175,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" at the exact","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":176,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" wording","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":177,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":178,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":179,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":180,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":181,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":182,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":183,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''ll use","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":184,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":185,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" ph","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":186,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"rasing in","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":187,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the query.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":188,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":189,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search(query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":190,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"=\"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":191,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a current-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":192,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"weather function and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":193,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":194,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-zone function\")","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":195,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`\nThis","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":196,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" should","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":197,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" work.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":198,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Let''s generate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":199,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":200,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":201,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nAll","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":202,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" good","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":203,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":204,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ing. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":205,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"[Self-C","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":206,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"orrection/","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":207,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Verification during thought","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":208,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"]\nThe","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":209,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt says:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":210,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Search the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":211,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" client tool catalog","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":212,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for tools that","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":213,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" can satisfy the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":214,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" request. Available","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":215,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" catalog entries:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":216,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" get_weather —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":217,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Get the current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":218,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather for a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":219,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" city; get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":220,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_exchange_rate —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":221,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Get the exchange","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":222,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" rate between two","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":223,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" currencies; search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":224,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_hotels —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":225,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Search for hotels","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":226,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in a city","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":227,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"; travel —","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":228,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Travel location tools","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":229,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\"\nSo","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":230,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":231,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_weather` and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":232,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `travel`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":233,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" are the ones","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":234,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":235,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will call `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":236,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":237,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":238,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":239,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that matches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":240,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" these.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":241,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":242,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":243,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"current weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":244,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and travel time","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":245,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" zone tools\"`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":246,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nReady.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":247,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \nOutput matches","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":248,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" expected","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":249,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" format.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":250,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Proceeds.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":251,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \n[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":252,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Generation] ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":253,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" calls tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":254,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n(Self","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":255,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-Correction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":256,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"/Refinement","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":257,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" during thought)","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":258,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nThe","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":259,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":260,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":261,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both a current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":262,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-weather function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":263,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and a travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":264,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time-zone function","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":265,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\"\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":266,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''ll","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":267,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" just put","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":268,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that exactly","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":269,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":270,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":271,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":272,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"current","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":273,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather and travel","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":274,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" time zone functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":275,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"`\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":276,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":277,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"s. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":278,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":279,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Generation","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":280,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"]\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":281,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"```","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":282,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"json\n{\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":283,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query\": \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":284,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":285,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":286,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" functions\"}\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":287,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"```\nWait","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":288,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":", the tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":289,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" definition","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":290,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" expects","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":291,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `query`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":292,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" as a string","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":293,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nI","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":294,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will generate","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":295,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the tool call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":296,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" now. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":297,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"All steps verified","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":298,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nProceed","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":299,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"s. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":300,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"[Final Check","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":301,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"]\n-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":302,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":303,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":304,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":305,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" exactly once?","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":306,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Yes.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":307,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"- Find","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":308,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":309,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"? The","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":310,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" query asks","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":311,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for them","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":312,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":313,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Do not call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":314,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" any other tool","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":315,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"?","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":316,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Yes.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":317,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":318,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Parameters","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":319,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" correct","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":320,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"? Yes","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":321,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":322,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nOutput","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":323,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" matches. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":324,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Proceeds.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":325,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \n[Output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":326,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Generation] (","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":327,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Internal","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":328,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" thought","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":329,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" process","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":330,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" complete","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":331,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":")\n`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":332,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":333,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with query \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":334,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":335,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":336,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" functions","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":337,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"\nDone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":338,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n(Self","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":339,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-Correction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":340,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"/Note:","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":341,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I should just","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":342,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" follow","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":343,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the instruction","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":344,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" exactly.)\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":345,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Calling","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":346,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":347,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \n[Done","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":348,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"] \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":349,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`{\"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":350,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"query\": \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":351,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"current weather and","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":352,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":353,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" functions\"}`","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":354,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":355,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" This","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":356,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is what","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":357,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I will output","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":358,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":359,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Wait, the","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":360,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt says \"","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":361,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"First","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":362,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call tool_search","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":363,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" exactly once to","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":364,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" find both a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":365,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" current-weather","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":366,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" function and a","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":367,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" travel time-zone","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":368,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" function.\"\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":369,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"I will just","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":370,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":371,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" it.\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":372,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Done. \n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":373,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Proceeds","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":374,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n[","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":375,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Output Generation]","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":376,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" ->","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":377,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" calls","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":378,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool_search.","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":379,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n","item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":380,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"sequence_number":381,"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` — Get the current weather for a city\n`travel` + — Travel location tools.\n\nI will construct a query that asks for current weather + and travel time zone tools.\n\nQuery: \"current weather and travel time zone + tools\"\n\nWait, the prompt says \"find both a current-weather function and + a travel time-zone function.\"\nI will call `tool_search` with this query.\nI + must ensure I only call it once.\n\nParameters:\nquery: \"current weather and + travel time zone tools\"\n\nLet''s do it. \nActually, the description for `travel` + says \"Travel location tools.\" It might not explicitly say time zone, but I''ll + search for it. The user''s instruction is to call `tool_search` exactly once + to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current weather + and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather for + a city; get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.\"\nSo `get_weather` + and `travel` are the ones.\nI will call `tool_search` with a query that matches + these.\n`query: \"current weather and travel time zone tools\"`\nReady. \nOutput + matches expected format.\nProceeds. \n[Output Generation] -> calls tool_search. + \n(Self-Correction/Refinement during thought)\nThe prompt says \"find both a + current-weather function and a travel time-zone function.\"\nI''ll just put + that exactly in the query.\n`query: \"current weather and travel time zone functions\"`\nDone. + \nProceeds. \n[Output Generation]\n```json\n{\"query\": \"current weather and + travel time zone functions\"}\n```\nWait, the tool definition expects `query` + as a string.\nI will generate the tool call now. \nAll steps verified. \nProceeds. + \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- Find both functions? + The query asks for them.\n- Do not call any other tool? Yes.\n- Parameters correct? + Yes.\nDone. \nOutput matches. \nProceeds. \n[Output Generation] (Internal thought + process complete)\n`tool_search` with query \"current weather and travel time + zone functions\"\nDone. \n(Self-Correction/Note: I should just follow the instruction + exactly.)\nCalling tool. \n[Done] \n`{\"query\": \"current weather and travel + time zone functions\"}` -> This is what I will output. \nWait, the prompt says + \"First call tool_search exactly once to find both a current-weather function + and a travel time-zone function.\"\nI will just call it.\nDone. \nProceeds. + \n[Output Generation] -> calls tool_search.\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"a7b8cae9f47f4dc2","output_index":0,"part":{"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` — Get the current weather for a city\n`travel` + — Travel location tools.\n\nI will construct a query that asks for current weather + and travel time zone tools.\n\nQuery: \"current weather and travel time zone + tools\"\n\nWait, the prompt says \"find both a current-weather function and + a travel time-zone function.\"\nI will call `tool_search` with this query.\nI + must ensure I only call it once.\n\nParameters:\nquery: \"current weather and + travel time zone tools\"\n\nLet''s do it. \nActually, the description for `travel` + says \"Travel location tools.\" It might not explicitly say time zone, but I''ll + search for it. The user''s instruction is to call `tool_search` exactly once + to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current weather + and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather for + a city; get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.\"\nSo `get_weather` + and `travel` are the ones.\nI will call `tool_search` with a query that matches + these.\n`query: \"current weather and travel time zone tools\"`\nReady. \nOutput + matches expected format.\nProceeds. \n[Output Generation] -> calls tool_search. + \n(Self-Correction/Refinement during thought)\nThe prompt says \"find both a + current-weather function and a travel time-zone function.\"\nI''ll just put + that exactly in the query.\n`query: \"current weather and travel time zone functions\"`\nDone. + \nProceeds. \n[Output Generation]\n```json\n{\"query\": \"current weather and + travel time zone functions\"}\n```\nWait, the tool definition expects `query` + as a string.\nI will generate the tool call now. \nAll steps verified. \nProceeds. + \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- Find both functions? + The query asks for them.\n- Do not call any other tool? Yes.\n- Parameters correct? + Yes.\nDone. \nOutput matches. \nProceeds. \n[Output Generation] (Internal thought + process complete)\n`tool_search` with query \"current weather and travel time + zone functions\"\nDone. \n(Self-Correction/Note: I should just follow the instruction + exactly.)\nCalling tool. \n[Done] \n`{\"query\": \"current weather and travel + time zone functions\"}` -> This is what I will output. \nWait, the prompt says + \"First call tool_search exactly once to find both a current-weather function + and a travel time-zone function.\"\nI will just call it.\nDone. \nProceeds. + \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"},"sequence_number":382,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to find two specific tools using + `tool_search`:\n1. A current-weather function\n2. A travel time-zone function\n\nI + need to call `tool_search` exactly once. I should construct a query that captures + the description of these capabilities.\nThe available tools listed in the system + prompt are: `get_weather`, `get_exchange_rate`, `search_hotels`, `travel`.\n`get_weather` + seems to match \"current-weather function\".\n`travel` seems to match \"travel + time-zone function\" (or perhaps I should search for \"time zone\" specifically).\n\nLet''s + formulate a query that searches for both or asks the tool to return results + for both types of capabilities. The prompt says \"Search the client tool catalog + for tools that can satisfy the request.\"\n\nQuery idea: \"tools for current + weather and travel time zones\"\n\nLet''s check the descriptions in the prompt:\n`get_weather` + — Get the current weather for a city\n`travel` — Travel location tools.\n\nI + will construct a query that asks for current weather and travel time zone tools.\n\nQuery: + \"current weather and travel time zone tools\"\n\nWait, the prompt says \"find + both a current-weather function and a travel time-zone function.\"\nI will call + `tool_search` with this query.\nI must ensure I only call it once.\n\nParameters:\nquery: + \"current weather and travel time zone tools\"\n\nLet''s do it. \nActually, + the description for `travel` says \"Travel location tools.\" It might not explicitly + say time zone, but I''ll search for it. The user''s instruction is to call `tool_search` + exactly once to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current + weather and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather for + a city; get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.\"\nSo `get_weather` + and `travel` are the ones.\nI will call `tool_search` with a query that matches + these.\n`query: \"current weather and travel time zone tools\"`\nReady. \nOutput + matches expected format.\nProceeds. \n[Output Generation] -> calls tool_search. + \n(Self-Correction/Refinement during thought)\nThe prompt says \"find both a + current-weather function and a travel time-zone function.\"\nI''ll just put + that exactly in the query.\n`query: \"current weather and travel time zone functions\"`\nDone. + \nProceeds. \n[Output Generation]\n```json\n{\"query\": \"current weather and + travel time zone functions\"}\n```\nWait, the tool definition expects `query` + as a string.\nI will generate the tool call now. \nAll steps verified. \nProceeds. + \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- Find both functions? + The query asks for them.\n- Do not call any other tool? Yes.\n- Parameters correct? + Yes.\nDone. \nOutput matches. \nProceeds. \n[Output Generation] (Internal thought + process complete)\n`tool_search` with query \"current weather and travel time + zone functions\"\nDone. \n(Self-Correction/Note: I should just follow the instruction + exactly.)\nCalling tool. \n[Done] \n`{\"query\": \"current weather and travel + time zone functions\"}` -> This is what I will output. \nWait, the prompt says + \"First call tool_search exactly once to find both a current-weather function + and a travel time-zone function.\"\nI will just call it.\nDone. \nProceeds. + \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":383,"type":"response.output_item.done"}' + - '{"item":{"arguments":{},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"in_progress","type":"tool_search_call"},"output_index":1,"sequence_number":384,"type":"response.output_item.added"}' + - '{"item":{"arguments":{"query":"current weather and travel time zone functions"},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"completed","type":"tool_search_call"},"output_index":1,"sequence_number":385,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787715707,"error":null,"id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to find two specific tools using `tool_search`:\n1. A current-weather + function\n2. A travel time-zone function\n\nI need to call `tool_search` exactly + once. I should construct a query that captures the description of these capabilities.\nThe + available tools listed in the system prompt are: `get_weather`, `get_exchange_rate`, + `search_hotels`, `travel`.\n`get_weather` seems to match \"current-weather function\".\n`travel` + seems to match \"travel time-zone function\" (or perhaps I should search for + \"time zone\" specifically).\n\nLet''s formulate a query that searches for both + or asks the tool to return results for both types of capabilities. The prompt + says \"Search the client tool catalog for tools that can satisfy the request.\"\n\nQuery + idea: \"tools for current weather and travel time zones\"\n\nLet''s check the + descriptions in the prompt:\n`get_weather` — Get the current weather for a city\n`travel` + — Travel location tools.\n\nI will construct a query that asks for current weather + and travel time zone tools.\n\nQuery: \"current weather and travel time zone + tools\"\n\nWait, the prompt says \"find both a current-weather function and + a travel time-zone function.\"\nI will call `tool_search` with this query.\nI + must ensure I only call it once.\n\nParameters:\nquery: \"current weather and + travel time zone tools\"\n\nLet''s do it. \nActually, the description for `travel` + says \"Travel location tools.\" It might not explicitly say time zone, but I''ll + search for it. The user''s instruction is to call `tool_search` exactly once + to find them.\n\nI will proceed. \nCall: `tool_search(query=\"current weather + and travel time zone tools\")`\nCheck constraints: \"First call tool_search + exactly once... Do not call any other tool yet.\" -> Satisfied.\nProceed. \nOutput + matches schema.\nDone. \nWait, let''s look at the exact wording: \"find both + a current-weather function and a travel time-zone function.\"\nI''ll use that + phrasing in the query.\n`tool_search(query=\"find a current-weather function + and a travel time-zone function\")`\nThis should work.\nLet''s generate the + call. \nAll good. \nProceeding. \n[Self-Correction/Verification during thought]\nThe + prompt says: \"Search the client tool catalog for tools that can satisfy the + request. Available catalog entries: get_weather — Get the current weather for + a city; get_exchange_rate — Get the exchange rate between two currencies; search_hotels + — Search for hotels in a city; travel — Travel location tools.\"\nSo `get_weather` + and `travel` are the ones.\nI will call `tool_search` with a query that matches + these.\n`query: \"current weather and travel time zone tools\"`\nReady. \nOutput + matches expected format.\nProceeds. \n[Output Generation] -> calls tool_search. + \n(Self-Correction/Refinement during thought)\nThe prompt says \"find both a + current-weather function and a travel time-zone function.\"\nI''ll just put + that exactly in the query.\n`query: \"current weather and travel time zone functions\"`\nDone. + \nProceeds. \n[Output Generation]\n```json\n{\"query\": \"current weather and + travel time zone functions\"}\n```\nWait, the tool definition expects `query` + as a string.\nI will generate the tool call now. \nAll steps verified. \nProceeds. + \n[Final Check]\n- Call `tool_search` exactly once? Yes.\n- Find both functions? + The query asks for them.\n- Do not call any other tool? Yes.\n- Parameters correct? + Yes.\nDone. \nOutput matches. \nProceeds. \n[Output Generation] (Internal thought + process complete)\n`tool_search` with query \"current weather and travel time + zone functions\"\nDone. \n(Self-Correction/Note: I should just follow the instruction + exactly.)\nCalling tool. \n[Done] \n`{\"query\": \"current weather and travel + time zone functions\"}` -> This is what I will output. \nWait, the prompt says + \"First call tool_search exactly once to find both a current-weather function + and a travel time-zone function.\"\nI will just call it.\nDone. \nProceeds. + \n[Output Generation] -> calls tool_search.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a7b8cae9f47f4dc2","status":null,"summary":[],"type":"reasoning"},{"arguments":{"query":"current + weather and travel time zone functions"},"call_id":"call_bca8ad3ee683ecb1","execution":"client","id":"tsc_cdf3e176e80c30ba","status":"completed","type":"tool_search_call"}],"previous_response_id":null,"status":"completed","tool_choice":"required","tools":[{"description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A + concise description of the needed capabilities.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","parameters":{"additionalProperties":false,"properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","parameters":{"additionalProperties":false,"properties":{"destination":{"type":"string"},"origin":{"type":"string"}},"required":["origin","destination"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":394,"input_tokens_details":{"cached_tokens":0},"output_tokens":963,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1357}},"sequence_number":386,"type":"response.completed"}' +- filename: t2 + request: + body: + input: + - call_id: call_bca8ad3ee683ecb1 + execution: client + status: completed + tools: + - defer_loading: true + description: Get the current weather for a city + name: get_weather + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace + type: tool_search_output + - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call + any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a03c28-cebc-7f43-b83c-186f9eb3b806 + store: true + tool_choice: + name: get_weather + type: function + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787715709,"frequency_penalty":0.0,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787715709,"frequency_penalty":0.0,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to call the","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_weather","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` function with","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the parameter","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `{\"city","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\": \"Paris","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"}`.","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nI found","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_weather`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `travel`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in the previous","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" step.\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"travel` tool","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is a","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" namespace, but","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the user specifically","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" asked for `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_weather`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" right","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" now.\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"I will execute","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the tool call","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":32,"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"part":{"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"reasoning_text"},"sequence_number":33,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to call the `get_weather` + function with the parameter `{\"city\": \"Paris\"}`.\nI found `get_weather` + and `travel` in the previous step.\nThe `travel` tool is a namespace, but the + user specifically asked for `get_weather` right now.\nI will execute the tool + call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":34,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"arguments":"","call_id":"call_9a756a715a7038af","caller":null,"id":"bceec48cd76816ce","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":35,"type":"response.output_item.added"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"{\"city\": \"","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":36,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"Paris","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":37,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"\"}","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":38,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.done + + data: {"arguments":"{\"city\": \"Paris\"}","item_id":"bceec48cd76816ce","name":"get_weather","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_9a756a715a7038af","caller":null,"id":"bceec48cd76816ce","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":40,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787715710,"error":null,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_9a756a715a7038af","id":"bceec48cd76816ce","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","status":"completed","tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":717,"input_tokens_details":{"cached_tokens":0},"output_tokens":99,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":816}},"sequence_number":41,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787715709,"frequency_penalty":0.0,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787715709,"frequency_penalty":0.0,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to call the","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_weather","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` function with","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the parameter","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `{\"city","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\": \"Paris","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"}`.","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nI found","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `travel`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the previous","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" step.\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel` tool","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is a","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" namespace, but","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the user specifically","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" asked for `","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" right","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" now.\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"I will execute","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the tool call","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n","item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"sequence_number":32,"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"a3098d30d6b5f4b1","output_index":0,"part":{"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"reasoning_text"},"sequence_number":33,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to call the `get_weather` function + with the parameter `{\"city\": \"Paris\"}`.\nI found `get_weather` and `travel` + in the previous step.\nThe `travel` tool is a namespace, but the user specifically + asked for `get_weather` right now.\nI will execute the tool call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":34,"type":"response.output_item.done"}' + - '{"item":{"arguments":"","call_id":"call_9a756a715a7038af","caller":null,"id":"bceec48cd76816ce","name":"get_weather","namespace":null,"status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":35,"type":"response.output_item.added"}' + - '{"delta":"{\"city\": \"","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":36,"type":"response.function_call_arguments.delta"}' + - '{"delta":"Paris","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":37,"type":"response.function_call_arguments.delta"}' + - '{"delta":"\"}","item_id":"bceec48cd76816ce","output_index":1,"sequence_number":38,"type":"response.function_call_arguments.delta"}' + - '{"arguments":"{\"city\": \"Paris\"}","item_id":"bceec48cd76816ce","name":"get_weather","output_index":1,"sequence_number":39,"type":"response.function_call_arguments.done"}' + - '{"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_9a756a715a7038af","caller":null,"id":"bceec48cd76816ce","name":"get_weather","namespace":null,"status":"completed","type":"function_call"},"output_index":1,"sequence_number":40,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787715710,"error":null,"id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_weather` function with the parameter `{\"city\": + \"Paris\"}`.\nI found `get_weather` and `travel` in the previous step.\nThe + `travel` tool is a namespace, but the user specifically asked for `get_weather` + right now.\nI will execute the tool call.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a3098d30d6b5f4b1","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_9a756a715a7038af","id":"bceec48cd76816ce","name":"get_weather","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-cebc-7f43-b83c-186f9eb3b806","status":"completed","tool_choice":{"name":"get_weather","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":717,"input_tokens_details":{"cached_tokens":0},"output_tokens":99,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":816}},"sequence_number":41,"type":"response.completed"}' +- filename: t3 + request: + body: + input: + - call_id: call_9a756a715a7038af + output: '{"city":"Paris","condition":"clear","temperature_c":21}' + type: function_call_output + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a + store: true + tool_choice: + name: get_timezone + namespace: travel + type: function + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787715710,"frequency_penalty":0.0,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787715710,"frequency_penalty":0.0,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"9cf2828127054765","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"9cf2828127054765","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"9cf2828127054765","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"9cf2828127054765","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to call the","item_id":"9cf2828127054765","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` function from","item_id":"9cf2828127054765","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the `travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` namespace with","item_id":"9cf2828127054765","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `city","item_id":"9cf2828127054765","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"=\"Paris\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`.\nThe","item_id":"9cf2828127054765","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" previous","item_id":"9cf2828127054765","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool search","item_id":"9cf2828127054765","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" result showed `","item_id":"9cf2828127054765","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"travel` is","item_id":"9cf2828127054765","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a namespace with","item_id":"9cf2828127054765","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" description","item_id":"9cf2828127054765","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" location tools\".","item_id":"9cf2828127054765","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nThe available","item_id":"9cf2828127054765","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tools include","item_id":"9cf2828127054765","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `agentic","item_id":"9cf2828127054765","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_ns__travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"__get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`","item_id":"9cf2828127054765","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" which matches","item_id":"9cf2828127054765","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the description \"","item_id":"9cf2828127054765","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Get the I","item_id":"9cf2828127054765","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ANA time zone","item_id":"9cf2828127054765","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for a city","item_id":"9cf2828127054765","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\".\nSo","item_id":"9cf2828127054765","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I will call","item_id":"9cf2828127054765","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `agentic","item_id":"9cf2828127054765","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_ns__travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"__get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` with `","item_id":"9cf2828127054765","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city:","item_id":"9cf2828127054765","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"Paris\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`.\nI","item_id":"9cf2828127054765","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" must","item_id":"9cf2828127054765","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" only","item_id":"9cf2828127054765","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" call this tool","item_id":"9cf2828127054765","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and","item_id":"9cf2828127054765","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" no","item_id":"9cf2828127054765","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" others.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city","item_id":"9cf2828127054765","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"Paris","item_id":"9cf2828127054765","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`.\nLet","item_id":"9cf2828127054765","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''s make","item_id":"9cf2828127054765","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the call.","item_id":"9cf2828127054765","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Wait, the","item_id":"9cf2828127054765","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" prompt","item_id":"9cf2828127054765","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" says \"call","item_id":"9cf2828127054765","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the loaded travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" namespace member get","item_id":"9cf2828127054765","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_timezone exactly once","item_id":"9cf2828127054765","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\". The tool","item_id":"9cf2828127054765","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" name in the","item_id":"9cf2828127054765","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" schema is `","item_id":"9cf2828127054765","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_timezone`.","item_id":"9cf2828127054765","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I will use","item_id":"9cf2828127054765","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Checking","item_id":"9cf2828127054765","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the","item_id":"9cf2828127054765","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" schema: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_timezone`,","item_id":"9cf2828127054765","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"{\"city\":","item_id":"9cf2828127054765","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" {\"type\":","item_id":"9cf2828127054765","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"string\"}}","item_id":"9cf2828127054765","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`.\nAll","item_id":"9cf2828127054765","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" good.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Proceeding.","item_id":"9cf2828127054765","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \nNote","item_id":"9cf2828127054765","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": I","item_id":"9cf2828127054765","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" need","item_id":"9cf2828127054765","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to format","item_id":"9cf2828127054765","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" it","item_id":"9cf2828127054765","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" correctly","item_id":"9cf2828127054765","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n`","item_id":"9cf2828127054765","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"get_timezone(city","item_id":"9cf2828127054765","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"=\"Paris\")","item_id":"9cf2828127054765","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"` or","item_id":"9cf2828127054765","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in","item_id":"9cf2828127054765","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" JSON format","item_id":"9cf2828127054765","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nI","item_id":"9cf2828127054765","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" will generate","item_id":"9cf2828127054765","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the tool call","item_id":"9cf2828127054765","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Done. \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Output","item_id":"9cf2828127054765","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" matches expectation","item_id":"9cf2828127054765","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \nProceed","item_id":"9cf2828127054765","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n[","item_id":"9cf2828127054765","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Self","item_id":"9cf2828127054765","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"-Correction","item_id":"9cf2828127054765","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"/Verification]","item_id":"9cf2828127054765","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nWait,","item_id":"9cf2828127054765","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the prompt says","item_id":"9cf2828127054765","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \"call","item_id":"9cf2828127054765","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the loaded travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" namespace member get","item_id":"9cf2828127054765","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_timezone exactly once","item_id":"9cf2828127054765","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\".","item_id":"9cf2828127054765","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" The tool name","item_id":"9cf2828127054765","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" in the catalog","item_id":"9cf2828127054765","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is `ag","item_id":"9cf2828127054765","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"entic_ns__","item_id":"9cf2828127054765","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"travel__get","item_id":"9cf2828127054765","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_timezone`. I","item_id":"9cf2828127054765","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" will use exactly","item_id":"9cf2828127054765","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" that.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city","item_id":"9cf2828127054765","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"`:","item_id":"9cf2828127054765","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" `\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Paris\"`","item_id":"9cf2828127054765","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\nReady.","item_id":"9cf2828127054765","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" \nOutput generation","item_id":"9cf2828127054765","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". \n[","item_id":"9cf2828127054765","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Done","item_id":"9cf2828127054765","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"]\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.done + + data: {"content_index":0,"item_id":"9cf2828127054765","output_index":0,"sequence_number":134,"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"response.reasoning_text.done"} + + ' + - 'event: response.reasoning_part.done + + data: {"content_index":0,"item_id":"9cf2828127054765","output_index":0,"part":{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"},"sequence_number":135,"type":"response.reasoning_part.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"content":[{"text":"The user wants me to call the `get_timezone` + function from the `travel` namespace with `city=\"Paris\"`.\nThe previous tool + search result showed `travel` is a namespace with description \"Travel location + tools\".\nThe available tools include `agentic_ns__travel__get_timezone` which + matches the description \"Get the IANA time zone for a city\".\nSo I will call + `agentic_ns__travel__get_timezone` with `city: \"Paris\"`.\nI must only call + this tool and no others.\nParameters: `city: \"Paris\"`.\nLet''s make the call. + \nWait, the prompt says \"call the loaded travel namespace member get_timezone + exactly once\". The tool name in the schema is `agentic_ns__travel__get_timezone`. + I will use that.\nChecking the schema: `agentic_ns__travel__get_timezone`, parameters: + `{\"city\": {\"type\": \"string\"}}`.\nAll good.\nProceeding. \nNote: I need + to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9cf2828127054765","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":136,"type":"response.output_item.done"} + + ' + - 'event: response.output_item.added + + data: {"item":{"arguments":"","call_id":"call_aad7ec9324a00032","caller":null,"id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":137,"type":"response.output_item.added"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"{\"city\": \"Paris","item_id":"bf24556c9a9d47bb","output_index":1,"sequence_number":138,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.delta + + data: {"delta":"\"}","item_id":"bf24556c9a9d47bb","output_index":1,"sequence_number":139,"type":"response.function_call_arguments.delta"} + + ' + - 'event: response.function_call_arguments.done + + data: {"arguments":"{\"city\": \"Paris\"}","item_id":"bf24556c9a9d47bb","name":"agentic_ns__travel__get_timezone","output_index":1,"sequence_number":140,"type":"response.function_call_arguments.done"} + + ' + - 'event: response.output_item.done + + data: {"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_aad7ec9324a00032","caller":null,"id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"},"output_index":1,"sequence_number":141,"type":"response.output_item.done"} + + ' + - 'event: response.completed + + data: {"response":{"conversation_id":null,"created_at":1787715712,"error":null,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9cf2828127054765","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_aad7ec9324a00032","id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","status":"completed","tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":801,"input_tokens_details":{"cached_tokens":0},"output_tokens":356,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1157}},"sequence_number":142,"type":"response.completed"} + + ' + - 'data: [DONE] + + ' + status_code: 101 + websocket: + - '{"response":{"background":false,"created_at":1787715710,"frequency_penalty":0.0,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787715710,"frequency_penalty":0.0,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"9cf2828127054765","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"9cf2828127054765","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"9cf2828127054765","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"9cf2828127054765","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to call the","item_id":"9cf2828127054765","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` function from","item_id":"9cf2828127054765","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the `travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` namespace with","item_id":"9cf2828127054765","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `city","item_id":"9cf2828127054765","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"=\"Paris\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.\nThe","item_id":"9cf2828127054765","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" previous","item_id":"9cf2828127054765","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool search","item_id":"9cf2828127054765","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" result showed `","item_id":"9cf2828127054765","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel` is","item_id":"9cf2828127054765","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a namespace with","item_id":"9cf2828127054765","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" description","item_id":"9cf2828127054765","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" location tools\".","item_id":"9cf2828127054765","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nThe available","item_id":"9cf2828127054765","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tools include","item_id":"9cf2828127054765","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `agentic","item_id":"9cf2828127054765","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_ns__travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"__get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`","item_id":"9cf2828127054765","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" which matches","item_id":"9cf2828127054765","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the description \"","item_id":"9cf2828127054765","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Get the I","item_id":"9cf2828127054765","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ANA time zone","item_id":"9cf2828127054765","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for a city","item_id":"9cf2828127054765","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\".\nSo","item_id":"9cf2828127054765","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I will call","item_id":"9cf2828127054765","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `agentic","item_id":"9cf2828127054765","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_ns__travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"__get_timezone","item_id":"9cf2828127054765","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` with `","item_id":"9cf2828127054765","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city:","item_id":"9cf2828127054765","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"Paris\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.\nI","item_id":"9cf2828127054765","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" must","item_id":"9cf2828127054765","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" only","item_id":"9cf2828127054765","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" call this tool","item_id":"9cf2828127054765","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and","item_id":"9cf2828127054765","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" no","item_id":"9cf2828127054765","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" others.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city","item_id":"9cf2828127054765","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"Paris","item_id":"9cf2828127054765","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.\nLet","item_id":"9cf2828127054765","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''s make","item_id":"9cf2828127054765","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the call.","item_id":"9cf2828127054765","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Wait, the","item_id":"9cf2828127054765","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" prompt","item_id":"9cf2828127054765","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" says \"call","item_id":"9cf2828127054765","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the loaded travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" namespace member get","item_id":"9cf2828127054765","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_timezone exactly once","item_id":"9cf2828127054765","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\". The tool","item_id":"9cf2828127054765","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" name in the","item_id":"9cf2828127054765","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" schema is `","item_id":"9cf2828127054765","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_timezone`.","item_id":"9cf2828127054765","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I will use","item_id":"9cf2828127054765","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Checking","item_id":"9cf2828127054765","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"9cf2828127054765","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" schema: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_timezone`,","item_id":"9cf2828127054765","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"{\"city\":","item_id":"9cf2828127054765","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" {\"type\":","item_id":"9cf2828127054765","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"string\"}}","item_id":"9cf2828127054765","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.\nAll","item_id":"9cf2828127054765","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" good.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Proceeding.","item_id":"9cf2828127054765","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \nNote","item_id":"9cf2828127054765","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": I","item_id":"9cf2828127054765","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" need","item_id":"9cf2828127054765","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to format","item_id":"9cf2828127054765","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" it","item_id":"9cf2828127054765","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" correctly","item_id":"9cf2828127054765","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n`","item_id":"9cf2828127054765","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"agentic_ns","item_id":"9cf2828127054765","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"__travel__","item_id":"9cf2828127054765","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_timezone(city","item_id":"9cf2828127054765","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"=\"Paris\")","item_id":"9cf2828127054765","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` or","item_id":"9cf2828127054765","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in","item_id":"9cf2828127054765","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" JSON format","item_id":"9cf2828127054765","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nI","item_id":"9cf2828127054765","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will generate","item_id":"9cf2828127054765","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the tool call","item_id":"9cf2828127054765","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Done. \n","item_id":"9cf2828127054765","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Output","item_id":"9cf2828127054765","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" matches expectation","item_id":"9cf2828127054765","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \nProceed","item_id":"9cf2828127054765","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n[","item_id":"9cf2828127054765","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Self","item_id":"9cf2828127054765","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"-Correction","item_id":"9cf2828127054765","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"/Verification]","item_id":"9cf2828127054765","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nWait,","item_id":"9cf2828127054765","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the prompt says","item_id":"9cf2828127054765","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \"call","item_id":"9cf2828127054765","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the loaded travel","item_id":"9cf2828127054765","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" namespace member get","item_id":"9cf2828127054765","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_timezone exactly once","item_id":"9cf2828127054765","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\".","item_id":"9cf2828127054765","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" The tool name","item_id":"9cf2828127054765","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the catalog","item_id":"9cf2828127054765","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is `ag","item_id":"9cf2828127054765","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"entic_ns__","item_id":"9cf2828127054765","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel__get","item_id":"9cf2828127054765","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_timezone`. I","item_id":"9cf2828127054765","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" will use exactly","item_id":"9cf2828127054765","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" that.\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Parameters: `","item_id":"9cf2828127054765","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city","item_id":"9cf2828127054765","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`:","item_id":"9cf2828127054765","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `\"","item_id":"9cf2828127054765","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Paris\"`","item_id":"9cf2828127054765","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nReady.","item_id":"9cf2828127054765","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" \nOutput generation","item_id":"9cf2828127054765","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". \n[","item_id":"9cf2828127054765","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Done","item_id":"9cf2828127054765","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"]\n","item_id":"9cf2828127054765","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"9cf2828127054765","output_index":0,"sequence_number":134,"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"9cf2828127054765","output_index":0,"part":{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"},"sequence_number":135,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to call the `get_timezone` function + from the `travel` namespace with `city=\"Paris\"`.\nThe previous tool search + result showed `travel` is a namespace with description \"Travel location tools\".\nThe + available tools include `agentic_ns__travel__get_timezone` which matches the + description \"Get the IANA time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` + with `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: + `city: \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the + loaded travel namespace member get_timezone exactly once\". The tool name in + the schema is `agentic_ns__travel__get_timezone`. I will use that.\nChecking + the schema: `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": + \"string\"}}`.\nAll good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9cf2828127054765","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":136,"type":"response.output_item.done"}' + - '{"item":{"arguments":"","call_id":"call_aad7ec9324a00032","caller":null,"id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"in_progress","type":"function_call"},"output_index":1,"sequence_number":137,"type":"response.output_item.added"}' + - '{"delta":"{\"city\": \"Paris","item_id":"bf24556c9a9d47bb","output_index":1,"sequence_number":138,"type":"response.function_call_arguments.delta"}' + - '{"delta":"\"}","item_id":"bf24556c9a9d47bb","output_index":1,"sequence_number":139,"type":"response.function_call_arguments.delta"}' + - '{"arguments":"{\"city\": \"Paris\"}","item_id":"bf24556c9a9d47bb","name":"agentic_ns__travel__get_timezone","output_index":1,"sequence_number":140,"type":"response.function_call_arguments.done"}' + - '{"item":{"arguments":"{\"city\": \"Paris\"}","call_id":"call_aad7ec9324a00032","caller":null,"id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"},"output_index":1,"sequence_number":141,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787715712,"error":null,"id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to call the `get_timezone` function from the `travel` namespace + with `city=\"Paris\"`.\nThe previous tool search result showed `travel` is a + namespace with description \"Travel location tools\".\nThe available tools include + `agentic_ns__travel__get_timezone` which matches the description \"Get the IANA + time zone for a city\".\nSo I will call `agentic_ns__travel__get_timezone` with + `city: \"Paris\"`.\nI must only call this tool and no others.\nParameters: `city: + \"Paris\"`.\nLet''s make the call. \nWait, the prompt says \"call the loaded + travel namespace member get_timezone exactly once\". The tool name in the schema + is `agentic_ns__travel__get_timezone`. I will use that.\nChecking the schema: + `agentic_ns__travel__get_timezone`, parameters: `{\"city\": {\"type\": \"string\"}}`.\nAll + good.\nProceeding. \nNote: I need to format it correctly.\n`agentic_ns__travel__get_timezone(city=\"Paris\")` + or in JSON format.\nI will generate the tool call. \nDone. \nOutput matches + expectation. \nProceed. \n[Self-Correction/Verification]\nWait, the prompt says + \"call the loaded travel namespace member get_timezone exactly once\". The tool + name in the catalog is `agentic_ns__travel__get_timezone`. I will use exactly + that.\nParameters: `city`: `\"Paris\"`\nReady. \nOutput generation. \n[Done]\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9cf2828127054765","status":null,"summary":[],"type":"reasoning"},{"arguments":"{\"city\": + \"Paris\"}","call_id":"call_aad7ec9324a00032","id":"bf24556c9a9d47bb","name":"get_timezone","namespace":"travel","status":"completed","type":"function_call"}],"previous_response_id":"resp_01a03c28-eb1e-7f92-b5a0-86fedc28f67a","status":"completed","tool_choice":{"name":"get_timezone","namespace":"travel","type":"function"},"tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":801,"input_tokens_details":{"cached_tokens":0},"output_tokens":356,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1157}},"sequence_number":142,"type":"response.completed"}' +- filename: t4 + request: + body: + input: + - call_id: call_aad7ec9324a00032 + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. + role: user + type: message + max_output_tokens: 4096 + model: Qwen/Qwen3.6-35B-A3B-FP8 + parallel_tool_calls: false + previous_response_id: resp_01a03c28-effe-7a91-b578-e382c29bdd22 + store: true + tool_choice: none + type: response.create + headers: {} + method: WEBSOCKET + path: /v1/responses + query_params: {} + transport: websocket + response: + headers: + transport: websocket + sse: + - 'event: response.created + + data: {"response":{"background":false,"created_at":1787715715,"frequency_penalty":0.0,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"} + + ' + - 'event: response.in_progress + + data: {"response":{"background":false,"created_at":1787715715,"frequency_penalty":0.0,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"} + + ' + - 'event: response.output_item.added + + data: {"item":{"content":null,"encrypted_content":null,"id":"8cbfc23565c7cd52","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"} + + ' + - 'event: response.reasoning_part.added + + data: {"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" user wants me","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to verify","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" if","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" tool","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" provided","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" are sufficient","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" complete","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" a","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" task and","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" then reply","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" with a specific","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" string \"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"PARIS_MIX","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ED_TOOLS","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_OK\".\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" task","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" implied","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" likely","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" getting","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather and timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for Paris","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nThe","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" are:\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"1. Weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Paris: {\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city\":\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Paris\",\"condition","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\":\"clear\",\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"temperature_c\":","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"21}","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n2.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Timezone for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Paris: {\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"city\":\"Paris","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\",\"iana_timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\":\"Europe/","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Paris\"}\n\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"The user instruction","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" says","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":": \"Use","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" both function outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and call no","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" more tools.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Reply with exactly","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" PARIS_MIXED","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_TOOLS_OK","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\"\n\nI","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" have the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" data","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" and","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" data for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Paris. I","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" have used","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" both","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" function outputs.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I am","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" calling","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" no more tools","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". I just","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" need to output","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the specific","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" string requested","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n\nPlan","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":":\n1","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". Confirm","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" have the outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\n2","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":". Confirm","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" I am","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" not calling any","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" more tools.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"\n3.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" Output \"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"PARIS_MIX","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"ED_TOOLS","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"_OK\".\n\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"Let","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":"''s double check","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" the previous turns","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" to","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" ensure no","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" context","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" is","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":" missing","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta + + data: {"content_index":0,"delta":".\nTurn","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"} + + ' + - 'event: response.reasoning_text.delta - data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"} + data: {"content_index":0,"delta":" 1:","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"The","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" user wants me","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"tool_search`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" to use","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" found `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the function output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"get_weather`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" from","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" and `travel","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" the previous step","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"`.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" (","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\nTurn ","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"which provided","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"2: `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" weather","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"get_weather`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" data","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" returned","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" for Paris)","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" for Paris.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\nTurn ","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":".\nI","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"3: `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" need to reply","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"travel","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" with exactly \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"` namespace","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" member `get","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"ATHER_OK\".","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"_timezone` (","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n\nPrevious","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"named","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" `ag","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":": {\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"entic_ns__","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"city\":\"Paris","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"travel__get","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\",\"condition\":\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"_timezone`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"clear\",\"temperature","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" in the execution","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"_c\":2","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":") returned timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"1}\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" for Paris.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"Instruction","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":"\nCurrent","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":": \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" Turn: User","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"Use the function","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" asks to use","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" output and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" and reply with","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":". Reply with","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" string","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" exactly PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":".\n\nThe","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"ATHER_OK.\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" request","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":"\n\nI will","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" is straightforward","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" just","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":". I will","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" output the required","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":" output the string","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.delta - data: {"content_index":0,"delta":" string.\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"} + data: {"content_index":0,"delta":".\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"} ' - 'event: response.reasoning_text.done - data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"sequence_number":42,"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"response.reasoning_text.done"} + data: {"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":140,"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"response.reasoning_text.done"} ' - 'event: response.reasoning_part.done - data: {"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"},"sequence_number":43,"type":"response.reasoning_part.done"} + data: {"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"part":{"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"},"sequence_number":141,"type":"response.reasoning_part.done"} ' - 'event: response.output_item.done - data: {"item":{"content":[{"text":"The user wants me to use the function output - from the previous step (which provided weather data for Paris) and call no more - tools.\nI need to reply with exactly \"PARIS_WEATHER_OK\".\n\nPrevious output: - {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":44,"type":"response.output_item.done"} + data: {"item":{"content":[{"text":"The user wants me to verify if the tool outputs + provided are sufficient to complete a task and then reply with a specific string + \"PARIS_MIXED_TOOLS_OK\".\nThe task implied is likely getting the weather and + timezone for Paris.\nThe outputs are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8cbfc23565c7cd52","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":142,"type":"response.output_item.done"} ' - 'event: response.output_item.added - data: {"item":{"content":[],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":45,"type":"response.output_item.added"} + data: {"item":{"content":[],"id":"aba0b5f455081834","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":143,"type":"response.output_item.added"} ' - 'event: response.content_part.added - data: {"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":46,"type":"response.content_part.added"} + data: {"content_index":0,"item_id":"aba0b5f455081834","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":144,"type":"response.content_part.added"} ' - 'event: response.output_text.delta - data: {"content_index":0,"delta":"\n\nPAR","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":47,"type":"response.output_text.delta"} + data: {"content_index":0,"delta":"\n\nPARIS","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":145,"type":"response.output_text.delta"} ' - 'event: response.output_text.delta - data: {"content_index":0,"delta":"IS_WEATHER","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":48,"type":"response.output_text.delta"} + data: {"content_index":0,"delta":"_MIXED_TO","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":146,"type":"response.output_text.delta"} ' - 'event: response.output_text.delta - data: {"content_index":0,"delta":"_OK","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":49,"type":"response.output_text.delta"} + data: {"content_index":0,"delta":"OLS_OK","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":147,"type":"response.output_text.delta"} ' - 'event: response.output_text.done - data: {"content_index":0,"item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":50,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"} + data: {"content_index":0,"item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":148,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"response.output_text.done"} ' - 'event: response.content_part.done - data: {"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"},"sequence_number":51,"type":"response.content_part.done"} + data: {"content_index":0,"item_id":"aba0b5f455081834","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"},"sequence_number":149,"type":"response.content_part.done"} ' - 'event: response.output_item.done - data: {"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":52,"type":"response.output_item.done"} + data: {"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"aba0b5f455081834","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":150,"type":"response.output_item.done"} ' - 'event: response.completed - data: {"response":{"conversation_id":null,"created_at":1787143220,"error":null,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","status":"completed","usage":{"input_tokens":634,"input_tokens_details":{"cached_tokens":0},"output_tokens":100,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":734}},"sequence_number":53,"type":"response.completed"} + data: {"response":{"conversation_id":null,"created_at":1787715717,"error":null,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8cbfc23565c7cd52","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"aba0b5f455081834","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","status":"completed","tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":346,"input_tokens_details":{"cached_tokens":0},"output_tokens":314,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":660}},"sequence_number":151,"type":"response.completed"} ' - 'data: [DONE] @@ -1225,82 +5175,227 @@ turns: ' status_code: 101 websocket: - - '{"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' - - '{"response":{"background":false,"created_at":1787143219,"frequency_penalty":0.0,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"additionalProperties":false,"properties":{"query":{"description":"A - concise description of the needed capability.","type":"string"}},"required":["query"],"type":"object"},"type":"tool_search"},{"defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' - - '{"item":{"content":null,"encrypted_content":null,"id":"a82a3a2c33325074","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' - - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' - - '{"content_index":0,"delta":"The","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" user wants me","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" to use","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the function output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" from","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" the previous step","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" (","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"which provided","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" weather","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" data","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" for Paris)","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":".\nI","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" need to reply","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" with exactly \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"ATHER_OK\".","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n\nPrevious","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" output","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":": {\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"city\":\"Paris","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\",\"condition\":\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"clear\",\"temperature","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"_c\":2","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"1}\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"Instruction","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":": \"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"Use the function","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" output and call","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" no more tools","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":". Reply with","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" exactly PARIS_WE","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"ATHER_OK.\"","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":"\n\nI will","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" just","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" output the required","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"delta":" string.\n","item_id":"a82a3a2c33325074","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' - - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"sequence_number":42,"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"response.reasoning_text.done"}' - - '{"content_index":0,"item_id":"a82a3a2c33325074","output_index":0,"part":{"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"},"sequence_number":43,"type":"response.reasoning_part.done"}' - - '{"item":{"content":[{"text":"The user wants me to use the function output from - the previous step (which provided weather data for Paris) and call no more tools.\nI - need to reply with exactly \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":44,"type":"response.output_item.done"}' - - '{"item":{"content":[],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":45,"type":"response.output_item.added"}' - - '{"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":46,"type":"response.content_part.added"}' - - '{"content_index":0,"delta":"\n\nPAR","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":47,"type":"response.output_text.delta"}' - - '{"content_index":0,"delta":"IS_WEATHER","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":48,"type":"response.output_text.delta"}' - - '{"content_index":0,"delta":"_OK","item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":49,"type":"response.output_text.delta"}' - - '{"content_index":0,"item_id":"aaca5e91e258cbe7","logprobs":[],"output_index":1,"sequence_number":50,"text":"\n\nPARIS_WEATHER_OK","type":"response.output_text.done"}' - - '{"content_index":0,"item_id":"aaca5e91e258cbe7","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"},"sequence_number":51,"type":"response.content_part.done"}' - - '{"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":52,"type":"response.output_item.done"}' - - '{"response":{"conversation_id":null,"created_at":1787143220,"error":null,"id":"resp_01a01a09-6961-7cd0-b700-09dd17ff54f8","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The - user wants me to use the function output from the previous step (which provided - weather data for Paris) and call no more tools.\nI need to reply with exactly - \"PARIS_WEATHER_OK\".\n\nPrevious output: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\nInstruction: - \"Use the function output and call no more tools. Reply with exactly PARIS_WEATHER_OK.\"\n\nI - will just output the required string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"a82a3a2c33325074","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_WEATHER_OK","type":"output_text"}],"id":"aaca5e91e258cbe7","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a01a09-660f-7b42-97dc-8390896b01d7","status":"completed","usage":{"input_tokens":634,"input_tokens_details":{"cached_tokens":0},"output_tokens":100,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":734}},"sequence_number":53,"type":"response.completed"}' + - '{"response":{"background":false,"created_at":1787715715,"frequency_penalty":0.0,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":0,"type":"response.created"}' + - '{"response":{"background":false,"created_at":1787715715,"frequency_penalty":0.0,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":4096,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null},"sequence_number":1,"type":"response.in_progress"}' + - '{"item":{"content":null,"encrypted_content":null,"id":"8cbfc23565c7cd52","status":"in_progress","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":2,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"part":{"text":"","type":"reasoning_text"},"sequence_number":3,"type":"response.reasoning_part.added"}' + - '{"content_index":0,"delta":"The","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":4,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" user wants me","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":5,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to verify","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":6,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" if","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":7,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":8,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" tool","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":9,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":10,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" provided","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":11,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" are sufficient","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":12,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":13,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" complete","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":14,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" a","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":15,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" task and","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":16,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" then reply","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":17,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" with a specific","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":18,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" string \"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":19,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"PARIS_MIX","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":20,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ED_TOOLS","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":21,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_OK\".\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":22,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":23,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" task","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":24,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" implied","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":25,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":26,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" likely","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":27,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" getting","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":28,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":29,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather and timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":30,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for Paris","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":31,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nThe","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":32,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":33,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" are:\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":34,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"1. Weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":35,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":36,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Paris: {\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":37,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city\":\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":38,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Paris\",\"condition","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":39,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\":\"clear\",\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":40,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"temperature_c\":","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":41,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"21}","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":42,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n2.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":43,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Timezone for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":44,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Paris: {\"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":45,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"city\":\"Paris","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":46,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\",\"iana_timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":47,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\":\"Europe/","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":48,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Paris\"}\n\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":49,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"The user instruction","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":50,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" says","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":51,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":": \"Use","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":52,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both function outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":53,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and call no","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":54,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" more tools.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":55,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Reply with exactly","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":56,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" PARIS_MIXED","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":57,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_TOOLS_OK","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":58,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\"\n\nI","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":59,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" have the","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":60,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":61,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" data","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":62,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":63,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":64,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" data for","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":65,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Paris. I","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":66,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" have used","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":67,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" both","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":68,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" function outputs.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":69,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I am","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":70,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" calling","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":71,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" no more tools","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":72,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". I just","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":73,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" need to output","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":74,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the specific","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":75,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" string requested","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":76,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n\nPlan","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":77,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":":\n1","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":78,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". Confirm","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":79,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":80,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" have the outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":81,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n2","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":82,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". Confirm","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":83,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" I am","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":84,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" not calling any","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":85,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" more tools.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":86,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\n3.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":87,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Output \"","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":88,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"PARIS_MIX","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":89,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"ED_TOOLS","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":90,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_OK\".\n\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":91,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"Let","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":92,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"''s double check","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":93,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" the previous turns","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":94,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" to","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":95,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" ensure no","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":96,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" context","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":97,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":98,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" missing","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":99,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\nTurn","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":100,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" 1:","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":101,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":102,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"tool_search`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":103,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" found `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":104,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":105,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and `travel","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":106,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"`.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":107,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nTurn ","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":108,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"2: `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":109,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"get_weather`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":110,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" returned","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":111,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" weather","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":112,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for Paris.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":113,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nTurn ","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":114,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"3: `","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":115,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":116,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"` namespace","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":117,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" member `get","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":118,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_timezone` (","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":119,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"named","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":120,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" `ag","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":121,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"entic_ns__","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":122,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"travel__get","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":123,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"_timezone`","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":124,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" in the execution","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":125,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":") returned timezone","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":126,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" for Paris.","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":127,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":"\nCurrent","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":128,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" Turn: User","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":129,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" asks to use","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":130,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" outputs","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":131,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" and reply with","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":132,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" string","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":133,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n\nThe","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":134,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" request","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":135,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" is straightforward","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":136,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":". I will","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":137,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":" output the string","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":138,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"delta":".\n","item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":139,"type":"response.reasoning_text.delta"}' + - '{"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"sequence_number":140,"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"response.reasoning_text.done"}' + - '{"content_index":0,"item_id":"8cbfc23565c7cd52","output_index":0,"part":{"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"},"sequence_number":141,"type":"response.reasoning_part.done"}' + - '{"item":{"content":[{"text":"The user wants me to verify if the tool outputs + provided are sufficient to complete a task and then reply with a specific string + \"PARIS_MIXED_TOOLS_OK\".\nThe task implied is likely getting the weather and + timezone for Paris.\nThe outputs are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8cbfc23565c7cd52","status":"completed","summary":[],"type":"reasoning"},"output_index":0,"sequence_number":142,"type":"response.output_item.done"}' + - '{"item":{"content":[],"id":"aba0b5f455081834","phase":null,"role":"assistant","status":"in_progress","type":"message"},"output_index":1,"sequence_number":143,"type":"response.output_item.added"}' + - '{"content_index":0,"item_id":"aba0b5f455081834","output_index":1,"part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"},"sequence_number":144,"type":"response.content_part.added"}' + - '{"content_index":0,"delta":"\n\nPARIS","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":145,"type":"response.output_text.delta"}' + - '{"content_index":0,"delta":"_MIXED_TO","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":146,"type":"response.output_text.delta"}' + - '{"content_index":0,"delta":"OLS_OK","item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":147,"type":"response.output_text.delta"}' + - '{"content_index":0,"item_id":"aba0b5f455081834","logprobs":[],"output_index":1,"sequence_number":148,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"response.output_text.done"}' + - '{"content_index":0,"item_id":"aba0b5f455081834","output_index":1,"part":{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"},"sequence_number":149,"type":"response.content_part.done"}' + - '{"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"aba0b5f455081834","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"},"output_index":1,"sequence_number":150,"type":"response.output_item.done"}' + - '{"response":{"conversation_id":null,"created_at":1787715717,"error":null,"id":"resp_01a03c29-014a-79f2-a0dd-3ccd8292e36f","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user wants me to verify if the tool outputs provided are sufficient to complete + a task and then reply with a specific string \"PARIS_MIXED_TOOLS_OK\".\nThe + task implied is likely getting the weather and timezone for Paris.\nThe outputs + are:\n1. Weather for Paris: {\"city\":\"Paris\",\"condition\":\"clear\",\"temperature_c\":21}\n2. + Timezone for Paris: {\"city\":\"Paris\",\"iana_timezone\":\"Europe/Paris\"}\n\nThe + user instruction says: \"Use both function outputs and call no more tools. Reply + with exactly PARIS_MIXED_TOOLS_OK.\"\n\nI have the weather data and the timezone + data for Paris. I have used both function outputs. I am calling no more tools. + I just need to output the specific string requested.\n\nPlan:\n1. Confirm I + have the outputs.\n2. Confirm I am not calling any more tools.\n3. Output \"PARIS_MIXED_TOOLS_OK\".\n\nLet''s + double check the previous turns to ensure no context is missing.\nTurn 1: `tool_search` + found `get_weather` and `travel`.\nTurn 2: `get_weather` returned weather for + Paris.\nTurn 3: `travel` namespace member `get_timezone` (named `agentic_ns__travel__get_timezone` + in the execution) returned timezone for Paris.\nCurrent Turn: User asks to use + outputs and reply with string.\n\nThe request is straightforward. I will output + the string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8cbfc23565c7cd52","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nPARIS_MIXED_TOOLS_OK","type":"output_text"}],"id":"aba0b5f455081834","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_01a03c28-effe-7a91-b578-e382c29bdd22","status":"completed","tool_choice":"none","tools":[{"description":"Get + the current weather for a city","name":"get_weather","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"},{"description":"Travel + location tools","name":"travel","tools":[{"description":"Get the IANA time zone + for a city","name":"get_timezone","parameters":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"strict":true,"type":"function"}],"type":"namespace"}],"usage":{"input_tokens":346,"input_tokens_details":{"cached_tokens":0},"output_tokens":314,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":660}},"sequence_number":151,"type":"response.completed"}' diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml index e2a4da4d..c2ca5676 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-nonstreaming.yaml @@ -2,30 +2,30 @@ turns: - filename: t1 request: body: - input: First call tool_search exactly once to find a weather tool. Do not call - get_weather yet. + input: First call tool_search exactly once to find both a current-weather function + and a travel time-zone function. Do not call any other tool yet. max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false store: true stream: false - tool_choice: auto + tool_choice: required tools: - - description: Search the client tool catalog for a tool that can satisfy the + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -37,6 +37,81 @@ turns: type: object strict: true type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace headers: accept: '*/*' authorization: Bearer *** @@ -50,11 +125,11 @@ turns: background: false billing: payer: developer - completed_at: 1787143193 - created_at: 1787143192 + completed_at: 1787714186 + created_at: 1787714184 error: null frequency_penalty: 0.0 - id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + id: resp_0d6efd6f8bc085ed006a8e5a883b9887d0b3b9ac1317ca2ba3 incomplete_details: null instructions: null max_output_tokens: 4096 @@ -65,11 +140,12 @@ turns: object: response output: - arguments: - query: Find a weather tool that can retrieve current weather or forecasts - for a specified location. - call_id: call_91mV1KhBtJ05ArOW1Y9Tqy9p + query: Find both (1) a function for current weather conditions by location + and (2) a travel time-zone function that can determine the destination/local + time zone (or time-zone difference) for travel. + call_id: call_kwlKLORMuHQL8SfNf00ewnkh execution: client - id: tsc_0380aab4c0242690006a85a418cf1c8198b139d12cac6deecb + id: tsc_0d6efd6f8bc085ed006a8e5a895ba487d0b3646b3c7b067034 status: completed type: tool_search_call parallel_tool_calls: false @@ -91,7 +167,7 @@ turns: format: type: text verbosity: medium - tool_choice: auto + tool_choice: required tool_usage: image_gen: input_tokens: 0 @@ -107,7 +183,7 @@ turns: num_requests: 0 tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather output_schema: null parameters: @@ -120,31 +196,111 @@ turns: type: object strict: true type: function - - description: Search the client tool catalog for a tool that can satisfy the + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + output_schema: null + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + output_schema: null + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace top_logprobs: 0 top_p: 0.98 truncation: disabled usage: - input_tokens: 158 + input_tokens: 166 input_tokens_details: cache_write_tokens: 0 cached_tokens: 0 - output_tokens: 35 + output_tokens: 58 output_tokens_details: reasoning_tokens: 0 - total_tokens: 193 + total_tokens: 224 user: null headers: content-type: application/json @@ -153,12 +309,12 @@ turns: request: body: input: - - call_id: call_91mV1KhBtJ05ArOW1Y9Tqy9p + - call_id: call_kwlKLORMuHQL8SfNf00ewnkh execution: client status: completed tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -170,18 +326,37 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace type: tool_search_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false - previous_response_id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a883b9887d0b3b9ac1317ca2ba3 store: true stream: false - tool_choice: auto + tool_choice: + name: get_weather + type: function headers: accept: '*/*' authorization: Bearer *** @@ -195,11 +370,11 @@ turns: background: false billing: payer: developer - completed_at: 1787143196 - created_at: 1787143193 + completed_at: 1787714188 + created_at: 1787714187 error: null frequency_penalty: 0.0 - id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + id: resp_0d6efd6f8bc085ed006a8e5a8b15ec87d0a35c204381e56962 incomplete_details: null instructions: null max_output_tokens: 4096 @@ -210,14 +385,14 @@ turns: object: response output: - arguments: '{"city":"Paris"}' - call_id: call_QPFOdlc7uFi7ErezgobLOClb - id: fc_0380aab4c0242690006a85a41b8dc081989bfd45b69d354b77 + call_id: call_xmP2yczPU7r7TSwBySUnjvDH + id: fc_0d6efd6f8bc085ed006a8e5a8be96c87d0aa5e12cee1328c7c name: get_weather status: completed type: function_call parallel_tool_calls: false presence_penalty: 0.0 - previous_response_id: resp_0380aab4c0242690006a85a41807bc8198a038cc4a06319b77 + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a883b9887d0b3b9ac1317ca2ba3 prompt_cache_key: null prompt_cache_retention: 24h reasoning: @@ -234,7 +409,9 @@ turns: format: type: text verbosity: medium - tool_choice: auto + tool_choice: + name: get_weather + type: function tool_usage: image_gen: input_tokens: 0 @@ -249,7 +426,7 @@ turns: web_search: num_requests: 0 tools: - - description: Get the current weather for a city. + - description: Get the current weather for a city name: get_weather output_schema: null parameters: @@ -262,18 +439,35 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace top_logprobs: 0 top_p: 0.98 truncation: disabled usage: - input_tokens: 228 + input_tokens: 307 input_tokens_details: cache_write_tokens: 0 cached_tokens: 0 output_tokens: 18 output_tokens_details: reasoning_tokens: 0 - total_tokens: 246 + total_tokens: 325 user: null headers: content-type: application/json @@ -282,17 +476,17 @@ turns: request: body: input: - - call_id: call_QPFOdlc7uFi7ErezgobLOClb + - call_id: call_xmP2yczPU7r7TSwBySUnjvDH output: '{"city":"Paris","condition":"clear","temperature_c":21}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. role: user type: message max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false - previous_response_id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a8b15ec87d0a35c204381e56962 store: true stream: false tool_choice: auto @@ -309,11 +503,143 @@ turns: background: false billing: payer: developer - completed_at: 1787143198 - created_at: 1787143197 + completed_at: 1787714190 + created_at: 1787714189 + error: null + frequency_penalty: 0.0 + id: resp_0d6efd6f8bc085ed006a8e5a8d5e1487d08cf945156d59472b + incomplete_details: null + instructions: null + max_output_tokens: 4096 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - arguments: '{"city":"Paris"}' + call_id: call_UCdhYlVJCK8uWaIKXHKcvdJd + id: fc_0d6efd6f8bc085ed006a8e5a8e9a7087d084360ac253a173c8 + name: get_timezone + namespace: travel + status: completed + type: function_call + parallel_tool_calls: false + presence_penalty: 0.0 + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a8b15ec87d0a35c204381e56962 + 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: Get the current weather for a city + name: get_weather + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 379 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 18 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 397 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t4 + request: + body: + input: + - call_id: call_UCdhYlVJCK8uWaIKXHKcvdJd + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a8d5e1487d08cf945156d59472b + store: true + stream: false + tool_choice: none + 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: 1787714192 + created_at: 1787714191 error: null frequency_penalty: 0.0 - id: resp_0380aab4c0242690006a85a41cdc948198b7bbbdc8ee5b8dbc + id: resp_0d6efd6f8bc085ed006a8e5a8f60e887d0a05c2bb86dfd4998 incomplete_details: null instructions: null max_output_tokens: 4096 @@ -326,16 +652,16 @@ turns: - content: - annotations: [] logprobs: [] - text: PARIS_WEATHER_OK + text: PARIS_MIXED_TOOLS_OK type: output_text - id: msg_0380aab4c0242690006a85a41dbdf08198acde9c13de42635a + id: msg_0d6efd6f8bc085ed006a8e5a9036f087d0be191c44dbfdddee phase: final_answer role: assistant status: completed type: message parallel_tool_calls: false presence_penalty: 0.0 - previous_response_id: resp_0380aab4c0242690006a85a419c6548198ac6a7f703e650341 + previous_response_id: resp_0d6efd6f8bc085ed006a8e5a8d5e1487d08cf945156d59472b prompt_cache_key: null prompt_cache_retention: 24h reasoning: @@ -352,7 +678,7 @@ turns: format: type: text verbosity: medium - tool_choice: auto + tool_choice: none tool_usage: image_gen: input_tokens: 0 @@ -367,7 +693,7 @@ turns: web_search: num_requests: 0 tools: - - description: Get the current weather for a city. + - description: Get the current weather for a city name: get_weather output_schema: null parameters: @@ -380,18 +706,35 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - description: Get the IANA time zone for a city + name: get_timezone + output_schema: null + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace top_logprobs: 0 top_p: 0.98 truncation: disabled usage: - input_tokens: 294 + input_tokens: 446 input_tokens_details: cache_write_tokens: 0 cached_tokens: 0 - output_tokens: 9 + output_tokens: 12 output_tokens_details: reasoning_tokens: 0 - total_tokens: 303 + total_tokens: 458 user: null headers: content-type: application/json diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml index 566e6160..24a3df71 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml +++ b/crates/agentic-server-core/tests/cassettes/tool_search/tool-search-openai-reference-gpt-5.6-streaming.yaml @@ -2,30 +2,30 @@ turns: - filename: t1 request: body: - input: First call tool_search exactly once to find a weather tool. Do not call - get_weather yet. + input: First call tool_search exactly once to find both a current-weather function + and a travel time-zone function. Do not call any other tool yet. max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false store: true stream: true - tool_choice: auto + tool_choice: required tools: - - description: Search the client tool catalog for a tool that can satisfy the + - description: Search the client tool catalog for tools that can satisfy the request. execution: client parameters: additionalProperties: false properties: query: - description: A concise description of the needed capability. + description: A concise description of the needed capabilities. type: string required: - query type: object type: tool_search - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -37,6 +37,81 @@ turns: type: object strict: true type: function + - defer_loading: true + description: Get the exchange rate between two currencies + name: get_exchange_rate + parameters: + additionalProperties: false + properties: + base: + type: string + quote: + type: string + required: + - base + - quote + type: object + strict: true + type: function + - defer_loading: true + description: Search for hotels in a city + name: search_hotels + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Get latitude and longitude for a city + name: get_coordinates + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + - defer_loading: true + description: Calculate the distance between two cities + name: calculate_distance + parameters: + additionalProperties: false + properties: + destination: + type: string + origin: + type: string + required: + - origin + - destination + type: object + strict: true + type: function + type: namespace headers: accept: '*/*' authorization: Bearer *** @@ -52,10 +127,16 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","response":{"id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + - 'data: {"type":"response.created","response":{"id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","object":"response","created_at":1787714194,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"required","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":"function","defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","output_schema":null,"parameters":{"type":"object","properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false}},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","output_schema":null,"parameters":{"type":"object","properties":{"origin":{"type":"string"},"destination":{"type":"string"}},"required":["origin","destination"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} ' - ' @@ -64,10 +145,16 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","response":{"id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + - 'data: {"type":"response.in_progress","response":{"id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","object":"response","created_at":1787714194,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"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":"required","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":"function","defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","output_schema":null,"parameters":{"type":"object","properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false}},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","output_schema":null,"parameters":{"type":"object","properties":{"origin":{"type":"string"},"destination":{"type":"string"}},"required":["origin","destination"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} ' - ' @@ -76,7 +163,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","item":{"id":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"in_progress","arguments":{},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"},"output_index":0,"sequence_number":2} + - 'data: {"type":"response.output_item.added","item":{"id":"tsc_09b6d06342202e77006a8e5a94076087d0b8b974aa62486abc","type":"tool_search_call","status":"in_progress","arguments":{},"call_id":"call_3SyAHfZ8PhLjASVy8iEFa2AQ","execution":"client"},"output_index":0,"sequence_number":2} ' - ' @@ -85,9 +172,10 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","item":{"id":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"completed","arguments":{"query":"Find - a tool that can retrieve current weather conditions or forecasts for a specified - location."},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"},"output_index":0,"sequence_number":3} + - 'data: {"type":"response.output_item.done","item":{"id":"tsc_09b6d06342202e77006a8e5a94076087d0b8b974aa62486abc","type":"tool_search_call","status":"completed","arguments":{"query":"Find + both (1) a function that returns current weather for a location and (2) a travel + time-zone function that determines a destination''s time zone or local time + for travel planning."},"call_id":"call_3SyAHfZ8PhLjASVy8iEFa2AQ","execution":"client"},"output_index":0,"sequence_number":3} ' - ' @@ -96,12 +184,19 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","object":"response","created_at":1787143200,"status":"completed","background":false,"completed_at":1787143201,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"tsc_0d17bf878012258e006a85a4217980819bb3cca44e8533ad2b","type":"tool_search_call","status":"completed","arguments":{"query":"Find - a tool that can retrieve current weather conditions or forecasts for a specified - location."},"call_id":"call_XZZLgyiW1cF7bDRZYpklBQQD","execution":"client"}],"parallel_tool_calls":false,"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":"function","defer_loading":true,"description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search - the client tool catalog for a tool that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A - concise description of the needed capability."}},"required":["query"],"additionalProperties":false}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":158,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":35,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":193},"user":null,"metadata":{}},"sequence_number":4} + - 'data: {"type":"response.completed","response":{"id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","object":"response","created_at":1787714194,"status":"completed","background":false,"completed_at":1787714196,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"tsc_09b6d06342202e77006a8e5a94076087d0b8b974aa62486abc","type":"tool_search_call","status":"completed","arguments":{"query":"Find + both (1) a function that returns current weather for a location and (2) a travel + time-zone function that determines a destination''s time zone or local time + for travel planning."},"call_id":"call_3SyAHfZ8PhLjASVy8iEFa2AQ","execution":"client"}],"parallel_tool_calls":false,"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":"required","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":"function","defer_loading":true,"description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + the exchange rate between two currencies","name":"get_exchange_rate","output_schema":null,"parameters":{"type":"object","properties":{"base":{"type":"string"},"quote":{"type":"string"}},"required":["base","quote"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Search + for hotels in a city","name":"search_hotels","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"tool_search","description":"Search + the client tool catalog for tools that can satisfy the request.","execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string","description":"A + concise description of the needed capabilities."}},"required":["query"],"additionalProperties":false}},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","defer_loading":true,"description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Get + latitude and longitude for a city","name":"get_coordinates","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"function","defer_loading":true,"description":"Calculate + the distance between two cities","name":"calculate_distance","output_schema":null,"parameters":{"type":"object","properties":{"origin":{"type":"string"},"destination":{"type":"string"}},"required":["origin","destination"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":166,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":56,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":222},"user":null,"metadata":{}},"sequence_number":4} ' - ' @@ -112,12 +207,12 @@ turns: request: body: input: - - call_id: call_XZZLgyiW1cF7bDRZYpklBQQD + - call_id: call_3SyAHfZ8PhLjASVy8iEFa2AQ execution: client status: completed tools: - defer_loading: true - description: Get the current weather for a city. + description: Get the current weather for a city name: get_weather parameters: additionalProperties: false @@ -129,18 +224,37 @@ turns: type: object strict: true type: function + - description: Travel location tools + name: travel + tools: + - defer_loading: true + description: Get the IANA time zone for a city + name: get_timezone + parameters: + additionalProperties: false + properties: + city: + type: string + required: + - city + type: object + strict: true + type: function + type: namespace type: tool_search_output - content: Now call get_weather exactly once with {"city":"Paris"}. Do not call - tool_search again. + any other tool. role: user type: message max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false - previous_response_id: resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba + previous_response_id: resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981 store: true stream: true - tool_choice: auto + tool_choice: + name: get_weather + type: function headers: accept: '*/*' authorization: Bearer *** @@ -156,8 +270,10 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","response":{"id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + - 'data: {"type":"response.created","response":{"id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","object":"response","created_at":1787714197,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","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":{"type":"function","name":"get_weather"},"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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} ' - ' @@ -166,8 +282,10 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","response":{"id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + - 'data: {"type":"response.in_progress","response":{"id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","object":"response","created_at":1787714197,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","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":{"type":"function","name":"get_weather"},"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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} ' - ' @@ -176,7 +294,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","item":{"id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"in_progress","arguments":"","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"},"output_index":0,"sequence_number":2} + - 'data: {"type":"response.output_item.added","item":{"id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","type":"function_call","status":"in_progress","arguments":"","call_id":"call_cUesiZFNF0gLgnw5j85LzWC0","name":"get_weather"},"output_index":0,"sequence_number":2} ' - ' @@ -185,7 +303,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"M3YKbiOKkGB6Um","output_index":0,"sequence_number":3} + - 'data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","obfuscation":"5rVjKITNgMDH4z","output_index":0,"sequence_number":3} ' - ' @@ -194,7 +312,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"fMK8xwuVIvSU","output_index":0,"sequence_number":4} + - 'data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","obfuscation":"oJCzUK8Gkyt6","output_index":0,"sequence_number":4} ' - ' @@ -203,7 +321,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"2bAEWyfSN5mEQ","output_index":0,"sequence_number":5} + - 'data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","obfuscation":"U1Zq3q7lNKvLd","output_index":0,"sequence_number":5} ' - ' @@ -212,7 +330,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"K4eksVqXSfe","output_index":0,"sequence_number":6} + - 'data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","obfuscation":"reFrhFQOt1s","output_index":0,"sequence_number":6} ' - ' @@ -221,7 +339,7 @@ turns: - 'event: response.function_call_arguments.delta ' - - 'data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","obfuscation":"HTl4OYLLiYMhBd","output_index":0,"sequence_number":7} + - 'data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","obfuscation":"Bdyq0fhBBSjZz4","output_index":0,"sequence_number":7} ' - ' @@ -230,7 +348,7 @@ turns: - 'event: response.function_call_arguments.done ' - - 'data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","output_index":0,"sequence_number":8} + - 'data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","output_index":0,"sequence_number":8} ' - ' @@ -239,7 +357,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","item":{"id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"},"output_index":0,"sequence_number":9} + - 'data: {"type":"response.output_item.done","item":{"id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_cUesiZFNF0gLgnw5j85LzWC0","name":"get_weather"},"output_index":0,"sequence_number":9} ' - ' @@ -248,8 +366,10 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","object":"response","created_at":1787143202,"status":"completed","background":false,"completed_at":1787143203,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"fc_0d17bf878012258e006a85a4233724819b98a98dae42aa5d63","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_nWSCHnCJpvRHoQGjLw2qbhQ9","name":"get_weather"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a420a500819b8917db8b9c5a30ba","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":228,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":246},"user":null,"metadata":{}},"sequence_number":10} + - 'data: {"type":"response.completed","response":{"id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","object":"response","created_at":1787714197,"status":"completed","background":false,"completed_at":1787714198,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"fc_09b6d06342202e77006a8e5a9687c087d08411dc30df8f3051","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_cUesiZFNF0gLgnw5j85LzWC0","name":"get_weather"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a92598887d08852a79bcc7fc981","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":{"type":"function","name":"get_weather"},"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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":305,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":323},"user":null,"metadata":{}},"sequence_number":10} ' - ' @@ -260,17 +380,17 @@ turns: request: body: input: - - call_id: call_nWSCHnCJpvRHoQGjLw2qbhQ9 + - call_id: call_cUesiZFNF0gLgnw5j85LzWC0 output: '{"city":"Paris","condition":"clear","temperature_c":21}' type: function_call_output - - content: Use the function output and call no more tools. Reply with exactly - PARIS_WEATHER_OK. + - content: Now call the loaded travel namespace member get_timezone exactly + once with {"city":"Paris"}. Do not call any other tool. role: user type: message max_output_tokens: 4096 model: gpt-5.6 parallel_tool_calls: false - previous_response_id: resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b + previous_response_id: resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca store: true stream: true tool_choice: auto @@ -289,8 +409,149 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","response":{"id":"resp_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + - 'data: {"type":"response.created","response":{"id":"resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","object":"response","created_at":1787714199,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"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_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","object":"response","created_at":1787714199,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"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":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","type":"function_call","status":"in_progress","arguments":"","call_id":"call_gP1EXR0KtNa0MCfzjh5fCsZQ","name":"get_timezone","namespace":"travel"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","obfuscation":"UtCM30Dyqigyxv","output_index":0,"sequence_number":3} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","obfuscation":"xXypsqWj1DjX","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","obfuscation":"losPggMyhFmvT","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","obfuscation":"iS39SPucwq2","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.function_call_arguments.delta + + ' + - 'data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","obfuscation":"5pUHuZkUbpKdXV","output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.function_call_arguments.done + + ' + - 'data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_gP1EXR0KtNa0MCfzjh5fCsZQ","name":"get_timezone","namespace":"travel"},"output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","object":"response","created_at":1787714199,"status":"completed","background":false,"completed_at":1787714201,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"fc_09b6d06342202e77006a8e5a98f23087d09eeb5d379d9284cd","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_gP1EXR0KtNa0MCfzjh5fCsZQ","name":"get_timezone","namespace":"travel"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a95861887d0bd00969c33f529ca","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":377,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":395},"user":null,"metadata":{}},"sequence_number":10} + + ' + - ' + + ' + status_code: 200 +- filename: t4 + request: + body: + input: + - call_id: call_gP1EXR0KtNa0MCfzjh5fCsZQ + output: '{"city":"Paris","iana_timezone":"Europe/Paris"}' + type: function_call_output + - content: Use both function outputs and call no more tools. Reply with exactly + PARIS_MIXED_TOOLS_OK. + role: user + type: message + max_output_tokens: 4096 + model: gpt-5.6 + parallel_tool_calls: false + previous_response_id: resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80 + store: true + stream: true + tool_choice: none + 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_09b6d06342202e77006a8e5a99cc8087d0a13c2695d1547e2b","object":"response","created_at":1787714201,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","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":"none","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} ' - ' @@ -299,8 +560,10 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","response":{"id":"resp_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + - 'data: {"type":"response.in_progress","response":{"id":"resp_09b6d06342202e77006a8e5a99cc8087d0a13c2695d1547e2b","object":"response","created_at":1787714201,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","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":"none","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} ' - ' @@ -309,7 +572,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","item":{"id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","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_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} ' - ' @@ -318,7 +581,34 @@ turns: - 'event: response.content_part.added ' - - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","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_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","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":"PAR","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"zeoN8KyyCd2VT","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"IS","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"KE1EZAjDnT4TDM","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_M","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"nWDqsrSg1OcL63","output_index":0,"sequence_number":6} ' - ' @@ -327,7 +617,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"PAR","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"mQlU8zt6x2Z9O","output_index":0,"sequence_number":4} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"IX","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"tAqnIifuPpWOQk","output_index":0,"sequence_number":7} ' - ' @@ -336,7 +626,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"IS","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"vfPELZDwKNwEGv","output_index":0,"sequence_number":5} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"ED","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"dyYFy0raO7i52N","output_index":0,"sequence_number":8} ' - ' @@ -345,7 +635,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_WE","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"D9r0s0ic0u1M4","output_index":0,"sequence_number":6} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_TO","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"ROBuypXCR2xEk","output_index":0,"sequence_number":9} ' - ' @@ -354,7 +644,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"ATHER","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"WLtB6sdOFlz","output_index":0,"sequence_number":7} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"OLS","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"NR7WSk37DNNsH","output_index":0,"sequence_number":10} ' - ' @@ -363,7 +653,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"obfuscation":"THEHz2fKYtwJM","output_index":0,"sequence_number":8} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"obfuscation":"Yi7XWBauOo8f9","output_index":0,"sequence_number":11} ' - ' @@ -372,7 +662,7 @@ turns: - 'event: response.output_text.done ' - - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","logprobs":[],"output_index":0,"sequence_number":9,"text":"PARIS_WEATHER_OK"} + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","logprobs":[],"output_index":0,"sequence_number":12,"text":"PARIS_MIXED_TOOLS_OK"} ' - ' @@ -381,7 +671,7 @@ turns: - 'event: response.content_part.done ' - - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"},"sequence_number":10} + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_MIXED_TOOLS_OK"},"sequence_number":13} ' - ' @@ -390,7 +680,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","item":{"id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":11} + - 'data: {"type":"response.output_item.done","item":{"id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_MIXED_TOOLS_OK"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":14} ' - ' @@ -399,8 +689,10 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","response":{"id":"resp_0d17bf878012258e006a85a424116c819b8f7413044c77cc15","object":"response","created_at":1787143204,"status":"completed","background":false,"completed_at":1787143204,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"msg_0d17bf878012258e006a85a424bb88819bb1089fa3bb167516","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_WEATHER_OK"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_0d17bf878012258e006a85a42275b0819baabbc76a5cd9f21b","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":"function","description":"Get - the current weather for a city.","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":294,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":9,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":303},"user":null,"metadata":{}},"sequence_number":12} + - 'data: {"type":"response.completed","response":{"id":"resp_09b6d06342202e77006a8e5a99cc8087d0a13c2695d1547e2b","object":"response","created_at":1787714201,"status":"completed","background":false,"completed_at":1787714202,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4096,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"msg_09b6d06342202e77006a8e5a9a6f8087d0836ff8285bd57d7c","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"PARIS_MIXED_TOOLS_OK"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":"resp_09b6d06342202e77006a8e5a97688887d089b8d9eed0314f80","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":"none","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":"function","description":"Get + the current weather for a city","name":"get_weather","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true},{"type":"namespace","description":"Travel + location tools","name":"travel","tools":[{"type":"function","description":"Get + the IANA time zone for a city","name":"get_timezone","output_schema":null,"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"strict":true}]}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":444,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":12,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":456},"user":null,"metadata":{}},"sequence_number":15} ' - ' diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json index 6de33939..c0c97683 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json +++ b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_initial_tools.json @@ -2,13 +2,13 @@ { "type": "function", "name": "tool_search", - "description": "Search the client tool catalog. Available catalog entry: get_weather — Get the current weather for a city.", + "description": "Search the client tool catalog for tools that can satisfy the request. Available catalog entries: get_weather — Get the current weather for a city; get_exchange_rate — Get the exchange rate between two currencies; search_hotels — Search for hotels in a city; travel — Travel location tools.", "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "A concise description of the needed capability." + "description": "A concise description of the needed capabilities." } }, "required": ["query"], diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tool_choice_sequence.json b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tool_choice_sequence.json new file mode 100644 index 00000000..89ba6c8b --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tool_choice_sequence.json @@ -0,0 +1,15 @@ +[ + { + "type": "function", + "name": "tool_search" + }, + { + "type": "function", + "name": "get_weather" + }, + { + "type": "function", + "name": "agentic_ns__travel__get_timezone" + }, + "none" +] diff --git a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json index 15c7a3b0..fe4a989a 100644 --- a/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json +++ b/crates/agentic-server-core/tests/cassettes/tool_search/vllm_tools_after_search.json @@ -2,13 +2,13 @@ { "type": "function", "name": "tool_search", - "description": "Search the client tool catalog. Available catalog entry: get_weather — Get the current weather for a city.", + "description": "Search the client tool catalog for tools that can satisfy the request. Available catalog entries: get_exchange_rate — Get the exchange rate between two currencies; search_hotels — Search for hotels in a city; travel — Travel location tools.", "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "A concise description of the needed capability." + "description": "A concise description of the needed capabilities." } }, "required": ["query"], @@ -19,7 +19,23 @@ { "type": "function", "name": "get_weather", - "description": "Get the current weather for a city.", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true + }, + { + "type": "function", + "name": "agentic_ns__travel__get_timezone", + "description": "Get the IANA time zone for a city", "parameters": { "type": "object", "properties": { diff --git a/crates/agentic-server-core/tests/event_normalizer_test.rs b/crates/agentic-server-core/tests/event_normalizer_test.rs index 1304e69e..7778178e 100644 --- a/crates/agentic-server-core/tests/event_normalizer_test.rs +++ b/crates/agentic-server-core/tests/event_normalizer_test.rs @@ -1,4 +1,5 @@ -use agentic_core::events::{EventPayload, SSEEventType, normalize_sse_line}; +use agentic_core::events::{EventPayload, SSEEventType, SSEItemType, normalize_sse_line}; +use agentic_core::types::tools::ToolSearchExecution; use serde::Deserialize; // --- Unit tests (per-event-type parsing) --- @@ -208,6 +209,8 @@ fn test_output_item_added_function_call() { name, namespace, call_id, + execution, + .. } = &frame.payload { assert_eq!(item_id, "fc_1"); @@ -216,6 +219,7 @@ fn test_output_item_added_function_call() { assert_eq!(name.as_deref(), Some("get_weather")); assert_eq!(namespace.as_deref(), Some("mcp__weather")); assert_eq!(call_id.as_deref(), Some("call_1")); + assert_eq!(*execution, None); } else { panic!("expected OutputItemAdded payload"); } @@ -704,6 +708,26 @@ fn test_call_id_from_output_item_added() { } } +#[test] +fn test_native_tool_search_call_added_is_typed() { + let line = r#"data: {"type":"response.output_item.added","item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}},"output_index":2,"sequence_number":4}"#; + let frame = normalize_sse_line(line).unwrap(); + + assert!(matches!( + frame.payload, + EventPayload::OutputItemAdded { + ref item_id, + item_type: SSEItemType::ToolSearchCall, + output_index: 2, + call_id: Some(ref call_id), + execution: Some(ToolSearchExecution::Client), + status: Some(ref status), + arguments: Some(ref arguments), + .. + } if item_id == "tsc_native" && call_id == "call_search" && status == "in_progress" && arguments.is_empty() + )); +} + #[test] fn test_custom_tool_input_stream_events_are_typed() { let delta = normalize_sse_line( diff --git a/crates/agentic-server-core/tests/stateful_conversation_integration.rs b/crates/agentic-server-core/tests/stateful_conversation_integration.rs index 90090eab..4b6c4184 100644 --- a/crates/agentic-server-core/tests/stateful_conversation_integration.rs +++ b/crates/agentic-server-core/tests/stateful_conversation_integration.rs @@ -59,7 +59,7 @@ async fn test_two_turn_nonstreaming_conversation() { } #[tokio::test] -async fn tool_search_conversation_continuation_recovers_effective_tools_from_latest_metadata() { +async fn tool_search_conversation_continuation_rebuilds_loaded_tools_from_history() { let fixture = TestFixture::new_with_responses(vec![ function_call_response("fc_search", "call_search", "tool_search", r#"{"query":"weather"}"#), function_call_response("fc_weather", "call_weather", "get_weather", r#"{"city":"Paris"}"#), @@ -142,7 +142,7 @@ async fn tool_search_conversation_continuation_recovers_effective_tools_from_lat .as_array() .unwrap() .iter() - .any(|tool| tool["name"] == "tool_search") + .all(|tool| tool["name"] != "tool_search") ); assert!(!request.to_string().contains("tool_search_call")); assert!(!request.to_string().contains("tool_search_output")); @@ -150,7 +150,7 @@ async fn tool_search_conversation_continuation_recovers_effective_tools_from_lat } #[tokio::test] -async fn tool_search_previous_response_branch_cannot_replace_conversation_metadata() { +async fn tool_search_previous_response_branch_cannot_replace_conversation_history() { let fixture = TestFixture::new_with_responses(vec![ function_call_response("fc_winner", "call_winner", "tool_search", r#"{"query":"winner"}"#), text_response("winner loaded"), diff --git a/crates/agentic-server-core/tests/stateful_responses_integration.rs b/crates/agentic-server-core/tests/stateful_responses_integration.rs index 617cefea..783822bf 100644 --- a/crates/agentic-server-core/tests/stateful_responses_integration.rs +++ b/crates/agentic-server-core/tests/stateful_responses_integration.rs @@ -816,9 +816,6 @@ async fn tool_search_store_false_manual_replay_completes_without_reusable_respon let lookup_ctx = RequestContext { original_request: make_request("lookup", true, false, Some(final_response.id.clone()), None), enriched_request: make_request("lookup", true, false, Some(final_response.id), None), - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: "resp_lookup".to_owned(), conversation_id: None, @@ -883,9 +880,6 @@ async fn test_previous_response_id_persists_inherited_tools_and_choice() { previous_response_id: Some(p2.id.clone()), ..make_request("lookup", true, false, None, None) }, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_lookup".into(), conversation_id: None, diff --git a/crates/agentic-server-core/tests/storage_integration.rs b/crates/agentic-server-core/tests/storage_integration.rs index cd359803..e60ca694 100644 --- a/crates/agentic-server-core/tests/storage_integration.rs +++ b/crates/agentic-server-core/tests/storage_integration.rs @@ -867,21 +867,20 @@ async fn test_response_store_get_after_persist() { } #[tokio::test] -async fn test_tool_search_conversation_snapshot_includes_latest_effective_metadata() { +async fn test_tool_search_conversation_metadata_matches_snapshot_version() { let pool = setup_pool().await; let store = ConversationStore::new(Arc::clone(&pool)); let conversation = store.create().await.expect("create conversation"); - let initial = ResponseMetadata { - model: "initial-model".to_owned(), - ..ResponseMetadata::default() - }; store .persist( &conversation.conversation_id, "resp_tool_search_initial", None, vec![create_input_item("initial")], - &initial, + &ResponseMetadata { + model: "initial-model".to_owned(), + ..ResponseMetadata::default() + }, ) .await .expect("persist initial turn"); @@ -911,8 +910,7 @@ async fn test_tool_search_conversation_snapshot_includes_latest_effective_metada .await .expect("persist latest turn"); - // Simulate replicas whose clocks and process-local UUID order disagree - // with the committed conversation-item sequence. + // Simulate replicas whose clocks and process-local UUID order disagree with committed conversation-item sequence. sqlx::query("UPDATE responses SET created_at = $1 WHERE id = $2") .bind(9_999_i64) .bind("resp_tool_search_initial") @@ -955,9 +953,24 @@ async fn test_tool_search_conversation_snapshot_includes_latest_effective_metada .rehydrate_snapshot(&conversation.conversation_id) .await .expect("rehydrate typed snapshot"); - let metadata = snapshot - .latest_response_metadata - .expect("latest response metadata accompanies conversation items"); + store + .persist( + &conversation.conversation_id, + "resp_after_snapshot", + None, + vec![create_input_item("after snapshot")], + &ResponseMetadata { + model: "after-snapshot-model".to_owned(), + ..ResponseMetadata::default() + }, + ) + .await + .expect("persist a newer conversation turn"); + let metadata = store + .response_metadata_at_version(&conversation.conversation_id, snapshot.version) + .await + .expect("load response metadata at snapshot version") + .expect("response metadata accompanies conversation items"); assert_eq!(metadata.model, "latest-model"); assert_eq!(metadata.tool_search_loaded_tools.as_deref().map(<[_]>::len), Some(1)); } @@ -1022,11 +1035,14 @@ async fn test_tool_search_conversation_conflict_does_not_persist_stale_loaded_st .expect_err("stale turn conflicts"); assert!(matches!(error, StorageError::ConversationConflict { .. })); - let latest = store + let snapshot = store .rehydrate_snapshot(&conversation.conversation_id) .await - .expect("rehydrate winning state") - .latest_response_metadata + .expect("rehydrate winning state"); + let latest = store + .response_metadata_at_version(&conversation.conversation_id, snapshot.version) + .await + .expect("load winning metadata") .expect("winning metadata"); assert_eq!(latest.model, "winner"); let serialized = serde_json::to_value(latest.tool_search_loaded_tools).unwrap(); @@ -1034,55 +1050,6 @@ async fn test_tool_search_conversation_conflict_does_not_persist_stale_loaded_st assert!(!serialized.to_string().contains("stale_tool")); } -#[tokio::test] -async fn test_tool_search_output_storage_round_trip_redacts_mcp_credentials() { - let pool = setup_pool().await; - let store = ResponseStore::new(pool); - let output: InputItem = serde_json::from_value(serde_json::json!({ - "type": "tool_search_output", - "call_id": "call_search_private", - "tools": [{ - "type": "mcp", - "server_label": "private_server", - "server_description": "Private server", - "server_url": "https://mcp.example.test/mcp", - "headers": {"X-API-Key": "header-secret"}, - "authorization": "authorization-secret", - "defer_loading": true - }] - })) - .expect("valid public search output"); - store - .persist( - "resp_tool_search_private", - None, - vec![InOutItem::Input(output)], - &ResponseMetadata::default(), - ) - .await - .expect("persist public search output"); - - let history = store - .rehydrate("resp_tool_search_private") - .await - .expect("rehydrate public search output"); - let InOutItem::Input(InputItem::ToolSearchOutput(output)) = &history[0] else { - panic!("public search output type must survive storage") - }; - let agentic_core::types::tools::ResponsesTool::Mcp(mcp) = &output.tools[0] else { - panic!("MCP declaration must survive storage") - }; - assert_eq!(mcp.server_label, "private_server"); - assert!(mcp.headers.is_none()); - assert!(mcp.authorization.is_none()); - assert!( - serde_json::to_value(mcp) - .expect("stored MCP serializes") - .get("_agentic_discovered_tools") - .is_none() - ); -} - #[tokio::test] async fn test_conversation_get_or_create_same_id() { let pool = setup_pool().await; diff --git a/crates/agentic-server-core/tests/tool_normalization_test.rs b/crates/agentic-server-core/tests/tool_normalization_test.rs index 399a648b..5cf035c9 100644 --- a/crates/agentic-server-core/tests/tool_normalization_test.rs +++ b/crates/agentic-server-core/tests/tool_normalization_test.rs @@ -99,9 +99,6 @@ fn upstream_request_value(payload: RequestPayload, stream: bool) -> Value { let ctx = RequestContext { original_request: payload.clone(), enriched_request: payload, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: Vec::new(), response_id: "resp_test".to_string(), conversation_id: None, diff --git a/crates/agentic-server-core/tests/tool_search_characterization_test.rs b/crates/agentic-server-core/tests/tool_search_characterization_test.rs index efd829da..c7366de6 100644 --- a/crates/agentic-server-core/tests/tool_search_characterization_test.rs +++ b/crates/agentic-server-core/tests/tool_search_characterization_test.rs @@ -5,6 +5,8 @@ use std::fs; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; +use agentic_core::RequestPayload; +use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; use serde_json::Value; #[derive(Clone, Copy)] @@ -18,11 +20,17 @@ struct SemanticFlow { execution: &'static str, status: &'static str, returned_tools: Value, - loaded_function_name: String, - function_output: Value, + loaded_calls: Vec, final_text: String, } +#[derive(Debug, PartialEq, Eq)] +struct LoadedCall { + namespace: Option, + name: String, + function_output: Value, +} + #[derive(Debug, PartialEq, Eq)] struct RelevantCallLifecycle { event_type: String, @@ -41,6 +49,13 @@ const GATEWAY_BLOCKING_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3 const GATEWAY_STREAMING_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml"; const GATEWAY_WEBSOCKET_CASSETTE: &str = "tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml"; +fn tool_search_cassette_directory() -> PathBuf { + std::env::var_os("TOOL_SEARCH_CASSETTE_DIR").map_or_else( + || Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"), + PathBuf::from, + ) +} + fn one_gateway_stream_cassette(directory: &Path, suffix: &str) -> PathBuf { let matches = fs::read_dir(directory) .expect("tool-search cassette directory") @@ -170,6 +185,99 @@ fn assert_stream_completed(events: &[Value]) { assert_eq!(completed[0]["response"]["status"], "completed"); } +fn response_lifecycle_metadata(turn: &support::Turn) -> Vec { + support::recorded_named_sse_events(turn) + .into_iter() + .filter(|event| { + matches!( + event["type"].as_str(), + Some("response.created" | "response.in_progress" | "response.completed") + ) + }) + .collect() +} + +fn assert_post_search_response_metadata(turn: &support::Turn, expected_tools: &Value) { + let events = response_lifecycle_metadata(turn); + assert_eq!( + events + .iter() + .map(|event| event["type"].as_str().expect("lifecycle event type")) + .collect::>(), + ["response.created", "response.in_progress", "response.completed"] + ); + let expected_choice = turn + .request + .body + .tool_choice + .as_ref() + .expect("tool-search flow turn should specify tool_choice"); + for event in events { + assert_eq!( + canonical_response_tools(&event["response"]["tools"]), + *expected_tools, + "{} must expose only public callable tools", + event["type"] + ); + assert_eq!( + &event["response"]["tool_choice"], expected_choice, + "{} must preserve the public tool choice", + event["type"] + ); + } +} + +fn canonical_response_tools(tools: &Value) -> Value { + let mut tools = tools.clone(); + let Some(tool_array) = tools.as_array_mut() else { + return tools; + }; + for tool in tool_array { + canonical_response_tool(tool, false); + } + tools +} + +fn canonical_response_tool(tool: &mut Value, make_callable: bool) { + let Some(tool) = tool.as_object_mut() else { + return; + }; + if make_callable { + tool.remove("defer_loading"); + } + if tool.get("output_schema").is_some_and(Value::is_null) { + tool.remove("output_schema"); + } + if let Some(members) = tool.get_mut("tools").and_then(Value::as_array_mut) { + for member in members { + canonical_response_tool(member, make_callable); + } + } +} + +fn expected_loaded_response_tools(directory: &Path) -> Value { + let mut tools = fixture_json(directory, "returned_tools.json"); + for tool in tools.as_array_mut().expect("returned tools fixture") { + canonical_response_tool(tool, true); + } + tools +} + +fn assert_loaded_response_tool_shape(tools: &Value) { + let tools = tools.as_array().expect("loaded response tools should be an array"); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[0]["name"], "get_weather"); + assert!(tools[0].get("defer_loading").is_none()); + assert_eq!(tools[1]["type"], "namespace"); + assert_eq!(tools[1]["name"], "travel"); + let members = tools[1]["tools"].as_array().expect("travel namespace members"); + assert_eq!(members.len(), 1); + assert_eq!(members[0]["type"], "function"); + assert_eq!(members[0]["name"], "get_timezone"); + assert!(members[0].get("defer_loading").is_none()); +} + fn assert_observed_call_lifecycle(turn: &support::Turn) -> (String, String, Value) { let events = support::recorded_named_sse_events(turn); assert_stream_completed(&events); @@ -367,14 +475,80 @@ fn normalize_search_step(response: &Value, continuation: &Value, projection: Pro } } -fn normalize_loaded_step(response: &Value, continuation: &Value, returned_tools: &Value) -> (String, Value) { +fn returned_identity_for_call( + loaded_call: &Value, + returned_tools: &Value, + projection: Projection, +) -> (Option, String) { + let raw_name = loaded_call["name"] + .as_str() + .expect("loaded function call should have a name"); + let raw_namespace = loaded_call.get("namespace").and_then(Value::as_str); + let returned_tools = returned_tools + .as_array() + .expect("search output tools should be an array"); + + if let Some(namespace) = raw_namespace { + assert!( + matches!(projection, Projection::Public), + "direct-vLLM calls should use a flattened model-visible name" + ); + let namespace_tool = returned_tools + .iter() + .find(|tool| tool["type"] == "namespace" && tool["name"] == namespace) + .expect("public namespace call should come from the search output"); + assert!( + namespace_tool["tools"] + .as_array() + .is_some_and(|members| members.iter().any(|member| member["name"] == raw_name)), + "public namespace member should come from the search output" + ); + return (Some(namespace.to_owned()), raw_name.to_owned()); + } + + if returned_tools + .iter() + .any(|tool| tool["type"] == "function" && tool["name"] == raw_name) + { + return (None, raw_name.to_owned()); + } + + assert!( + matches!(projection, Projection::Normalized), + "public namespace calls should preserve their namespace" + ); + for namespace_tool in returned_tools.iter().filter(|tool| tool["type"] == "namespace") { + let namespace = namespace_tool["name"] + .as_str() + .expect("returned namespace should have a name"); + for member in namespace_tool["tools"] + .as_array() + .expect("returned namespace should contain members") + { + let member_name = member["name"] + .as_str() + .expect("returned namespace member should have a name"); + if model_visible_namespace_member_name(namespace, member_name) == raw_name { + return (Some(namespace.to_owned()), member_name.to_owned()); + } + } + } + panic!("called function should come from the search output: {raw_name}"); +} + +fn normalize_loaded_step( + response: &Value, + continuation: &Value, + returned_tools: &Value, + projection: Projection, +) -> LoadedCall { let output = response["output"] .as_array() - .expect("second response output should be an array"); + .expect("loaded-tool response output should be an array"); assert_eq!( client_calls(output).len(), 1, - "second response should contain exactly one client call" + "each loaded-tool response should contain exactly one client call" ); let loaded_calls = output .iter() @@ -383,23 +557,12 @@ fn normalize_loaded_step(response: &Value, continuation: &Value, returned_tools: assert_eq!( loaded_calls.len(), 1, - "second response should call exactly one loaded function" + "loaded-tool response should call exactly one loaded function" ); let loaded_call = loaded_calls[0]; let loaded_call_id = non_empty_call_id(loaded_call); assert_eq!(loaded_call["status"], "completed"); - let loaded_function_name = loaded_call["name"] - .as_str() - .expect("loaded function call should have a name") - .to_string(); - assert!( - returned_tools - .as_array() - .expect("search output tools should be an array") - .iter() - .any(|tool| tool["name"].as_str() == Some(&loaded_function_name)), - "called function should come from the search output" - ); + let (namespace, name) = returned_identity_for_call(loaded_call, returned_tools, projection); let loaded_arguments = loaded_call["arguments"] .as_str() .and_then(|arguments| serde_json::from_str::(arguments).ok()) @@ -417,7 +580,11 @@ fn normalize_loaded_step(response: &Value, continuation: &Value, returned_tools: 1, "exactly one function output should link to the loaded call" ); - (loaded_function_name, function_outputs[0]["output"].clone()) + LoadedCall { + namespace, + name, + function_output: function_outputs[0]["output"].clone(), + } } fn normalized_final_text(response: &Value) -> String { @@ -440,39 +607,149 @@ fn normalized_final_text(response: &Value) -> String { } fn normalize_flow(responses: &[Value], continuation_inputs: &[Value], projection: Projection) -> SemanticFlow { - assert_eq!(responses.len(), 3, "tool-search characterization needs three responses"); + assert_eq!(responses.len(), 4, "tool-search characterization needs four responses"); assert_eq!( continuation_inputs.len(), - 2, - "tool-search characterization needs two continuations" + 3, + "tool-search characterization needs three continuations" ); let returned_tools = normalize_search_step(&responses[0], &continuation_inputs[0], projection); - let (loaded_function_name, function_output) = - normalize_loaded_step(&responses[1], &continuation_inputs[1], &returned_tools); + let loaded_calls = vec![ + normalize_loaded_step(&responses[1], &continuation_inputs[1], &returned_tools, projection), + normalize_loaded_step(&responses[2], &continuation_inputs[2], &returned_tools, projection), + ]; + assert_eq!( + loaded_calls + .iter() + .map(|call| (call.namespace.as_deref(), call.name.as_str())) + .collect::>(), + [(None, "get_weather"), (Some("travel"), "get_timezone")], + "the flow must call only the selected ordinary function and selected namespace member" + ); SemanticFlow { execution: "client", status: "completed", returned_tools, - loaded_function_name, - function_output, - final_text: normalized_final_text(&responses[2]), + loaded_calls, + final_text: normalized_final_text(&responses[3]), } } -fn weather_tool_definition() -> Value { - serde_json::json!([{ - "type": "function", - "name": "get_weather", - "description": "Get weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - "additionalProperties": false +fn mixed_loaded_tool_definitions() -> Value { + serde_json::json!([ + { + "type": "function", + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true }, - "strict": true, - "defer_loading": true - }]) + { + "type": "namespace", + "name": "travel", + "description": "Travel tools", + "tools": [{ + "type": "function", + "name": "get_timezone", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }] + } + ]) +} + +fn fixture_json(directory: &Path, filename: &str) -> Value { + serde_json::from_str( + &fs::read_to_string(directory.join(filename)) + .unwrap_or_else(|error| panic!("{filename} should be readable: {error}")), + ) + .unwrap_or_else(|error| panic!("{filename} should be valid JSON: {error}")) +} + +fn lowered_fixture_tools(tools: &Value, input: &Value) -> Value { + let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "fixture-model", + "input": input, + "tools": tools, + "store": false, + "stream": false, + "parallel_tool_calls": false + })) + .expect("fixture should deserialize as a public request"); + let mut state = ToolSearchState::build(&request).expect("fixture should build tool-search state"); + state + .prepare_inference_request(&mut request) + .expect("fixture should prepare private inference state"); + let upstream = request + .to_upstream_request(false) + .expect("fixture should lower into an upstream request"); + serde_json::to_value(upstream).expect("upstream fixture should serialize")["tools"].clone() +} + +#[test] +fn mixed_catalog_fixtures_match_private_tool_search_lowering() { + let directory = tool_search_cassette_directory(); + let public_tools = fixture_json(&directory, "openai_tools.json"); + let returned_tools = fixture_json(&directory, "returned_tools.json"); + let expected_initial = fixture_json(&directory, "vllm_initial_tools.json"); + let expected_loaded = fixture_json(&directory, "vllm_tools_after_search.json"); + let openai_tool_choices = fixture_json(&directory, "openai_tool_choice_sequence.json"); + let gateway_tool_choices = fixture_json(&directory, "gateway_tool_choice_sequence.json"); + + for choice in [openai_tool_choices, gateway_tool_choices].iter().flat_map(|choices| { + choices + .as_array() + .expect("public tool-choice sequence should be an array") + }) { + serde_json::from_value::(serde_json::json!({ + "model": "fixture-model", + "input": "fixture input", + "tools": public_tools, + "tool_choice": choice, + "parallel_tool_calls": false + })) + .expect("every public tool-choice fixture should match the typed request model"); + } + + assert_eq!( + lowered_fixture_tools(&public_tools, &serde_json::json!("find weather and timezone tools")), + expected_initial + ); + assert_eq!( + lowered_fixture_tools( + &public_tools, + &serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_fixture", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather and timezone"} + }, + { + "type": "tool_search_output", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "tools": returned_tools + } + ]) + ), + expected_loaded + ); } fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { @@ -491,7 +768,7 @@ fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { }] }), serde_json::json!({ - "id": "resp_public_function", + "id": "resp_public_weather", "reasoning": {"summary": "provider noise"}, "output": [{ "id": "fc_public", @@ -502,11 +779,23 @@ fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { "arguments": "{\"city\":\"Paris\"}" }] }), + serde_json::json!({ + "id": "resp_public_timezone", + "output": [{ + "id": "fc_public_timezone", + "type": "function_call", + "namespace": "travel", + "name": "get_timezone", + "call_id": "call_public_timezone", + "status": "completed", + "arguments": "{\"city\":\"Paris\"}" + }] + }), serde_json::json!({ "id": "resp_public_final", "output": [{ "type": "message", - "content": [{"type": "output_text", "text": "\n\nPARIS_WEATHER_OK"}] + "content": [{"type": "output_text", "text": "\n\nPARIS_MIXED_TOOLS_OK"}] }] }), ]; @@ -523,6 +812,11 @@ fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { "call_id": "call_public_function", "output": "weather result" }]), + serde_json::json!([{ + "type": "function_call_output", + "call_id": "call_public_timezone", + "output": "timezone result" + }]), ]; (responses, inputs) } @@ -543,7 +837,7 @@ fn normalized_semantic_fixture(returned_tools: &Value) -> (Vec, Vec (Vec, Vec (Vec, Vec Vec { - vec![ - serde_json::json!([ - {"type": "message", "role": "user", "content": "find a weather tool"}, - responses[0]["output"][0].clone(), - inputs[0][0].clone(), - {"type": "message", "role": "user", "content": "call it"} - ]), - serde_json::json!([ - {"type": "message", "role": "user", "content": "find a weather tool"}, - responses[0]["output"][0].clone(), - inputs[0][0].clone(), - {"type": "message", "role": "user", "content": "call it"}, - responses[1]["output"][0].clone(), - inputs[1][0].clone(), - {"type": "message", "role": "user", "content": "finish"} - ]), - ] + let prompts = ["call weather", "call timezone", "finish"]; + let mut history = vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": "find weather and timezone tools" + })]; + responses + .iter() + .zip(inputs) + .zip(prompts) + .map(|((response, input), prompt)| { + history.extend( + response["output"] + .as_array() + .expect("fixture response output") + .iter() + .cloned(), + ); + history.extend(input.as_array().expect("fixture continuation input").iter().cloned()); + history.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": prompt + })); + Value::Array(history.clone()) + }) + .collect() } fn assert_semantic_mutations_are_visible( @@ -618,6 +938,14 @@ fn assert_semantic_mutations_are_visible( "client function-output mutations must remain semantically visible" ); + let mut namespace_output_mutation = normalized_inputs.to_vec(); + namespace_output_mutation[2][0]["output"] = Value::String("different timezone".to_string()); + let mutated = normalize_flow(normalized_responses, &namespace_output_mutation, Projection::Normalized); + assert_ne!( + public, &mutated, + "client namespace-member output mutations must remain semantically visible" + ); + let mut status_mutation = public_responses.to_vec(); status_mutation[0]["output"][0]["status"] = Value::String("in_progress".to_string()); assert!( @@ -628,7 +956,11 @@ fn assert_semantic_mutations_are_visible( "public status mutation should be rejected" ); - for (output_index, label) in [(0, "normalized search"), (1, "normalized loaded function")] { + for (output_index, label) in [ + (0, "normalized search"), + (1, "normalized loaded function"), + (2, "normalized loaded namespace member"), + ] { let mut status_mutation = normalized_responses.to_vec(); status_mutation[output_index]["output"][0]["status"] = Value::String("in_progress".to_string()); assert!( @@ -649,11 +981,21 @@ fn assert_semantic_mutations_are_visible( .is_err(), "call linkage mutation should be rejected" ); + + let mut namespace_linkage_mutation = public_inputs.to_vec(); + namespace_linkage_mutation[2][0]["call_id"] = Value::String("call_wrong".to_string()); + assert!( + catch_unwind(AssertUnwindSafe(|| { + normalize_flow(public_responses, &namespace_linkage_mutation, Projection::Public) + })) + .is_err(), + "namespace-member call linkage mutation should be rejected" + ); } #[test] fn raw_semantic_normalization_ignores_provider_ids_usage_and_wire_projection() { - let returned_tools = weather_tool_definition(); + let returned_tools = mixed_loaded_tool_definitions(); let (public_responses, public_inputs) = public_semantic_fixture(&returned_tools); let (normalized_responses, normalized_inputs) = normalized_semantic_fixture(&returned_tools); let public = normalize_flow(&public_responses, &public_inputs, Projection::Public); @@ -686,7 +1028,7 @@ fn offline_sse_terminal_normalization_ignores_event_chunk_grouping() { "id": "resp_terminal", "output": [{ "type": "message", - "content": [{"type": "output_text", "text": "PARIS_WEATHER_OK"}] + "content": [{"type": "output_text", "text": "PARIS_MIXED_TOOLS_OK"}] }] } }); @@ -761,9 +1103,31 @@ fn named_sse_call_lifecycle_projection_preserves_order_and_linkage() { ); } -#[test] -fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { - let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); +fn assert_reference_and_blocking_response_metadata(directory: &Path) -> (Value, SemanticFlow) { + let openai_streaming = support::load_cassette( + directory + .join(OPENAI_STREAMING_CASSETTE) + .to_str() + .expect("OpenAI streaming cassette path"), + ); + let expected_loaded_tools = expected_loaded_response_tools(directory); + assert_loaded_response_tool_shape(&expected_loaded_tools); + for turn in &openai_streaming.turns[1..] { + assert_post_search_response_metadata(turn, &expected_loaded_tools); + } + + let openai_blocking = support::load_cassette( + directory + .join(OPENAI_BLOCKING_CASSETTE) + .to_str() + .expect("OpenAI blocking cassette path"), + ); + for turn in &openai_blocking.turns[1..] { + let response = terminal_response(turn); + assert_eq!(canonical_response_tools(&response["tools"]), expected_loaded_tools); + assert_eq!(response["tool_choice"], turn.request.body.tool_choice.clone().unwrap()); + } + let blocking = support::load_cassette( directory .join(GATEWAY_BLOCKING_CASSETTE) @@ -771,16 +1135,27 @@ fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { .expect("blocking gateway cassette path"), ); let blocking_responses = blocking.turns.iter().map(terminal_response).collect::>(); + for (turn, response) in blocking.turns[1..].iter().zip(&blocking_responses[1..]) { + assert_eq!(canonical_response_tools(&response["tools"]), expected_loaded_tools); + assert_eq!(response["tool_choice"], turn.request.body.tool_choice.clone().unwrap()); + } let blocking_inputs = blocking.turns[1..] .iter() .map(|turn| turn.request.body.input.clone()) .collect::>(); let blocking_flow = normalize_flow(&blocking_responses, &blocking_inputs, Projection::Public); + (expected_loaded_tools, blocking_flow) +} + +#[test] +fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { + let directory = tool_search_cassette_directory(); + let (expected_loaded_tools, blocking_flow) = assert_reference_and_blocking_response_metadata(&directory); for suffix in ["-streaming.yaml", "-websocket.yaml"] { let path = one_gateway_stream_cassette(&directory, suffix); let cassette = support::load_cassette(path.to_str().expect("gateway stream cassette path")); - assert_eq!(cassette.turns.len(), 3); + assert_eq!(cassette.turns.len(), 4); for turn in &cassette.turns { let events = support::recorded_named_sse_events(turn); @@ -804,6 +1179,9 @@ fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { }) })); } + for turn in &cassette.turns[1..] { + assert_post_search_response_metadata(turn, &expected_loaded_tools); + } let first_events = support::recorded_named_sse_events(&cassette.turns[0]); assert!(first_events.iter().any(|event| { @@ -814,6 +1192,16 @@ fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { && tools .iter() .any(|tool| tool["name"] == "get_weather" && tool["defer_loading"] == true) + && tools + .iter() + .filter(|tool| tool["type"] == "function" && tool["defer_loading"] == true) + .count() + == 3 + && tools.iter().any(|tool| { + tool["type"] == "namespace" + && tool["name"] == "travel" + && tool["tools"].as_array().is_some_and(|members| members.len() == 3) + }) }) })); assert!(first_events.iter().all(|event| { @@ -861,11 +1249,9 @@ fn gateway_http_sse_and_websocket_cassettes_replay_the_public_lifecycle() { #[test] fn openai_streaming_cassette_preserves_public_lifecycle_and_terminal_identity() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/cassettes/tool_search") - .join(OPENAI_STREAMING_CASSETTE); + let path = tool_search_cassette_directory().join(OPENAI_STREAMING_CASSETTE); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); - assert_eq!(cassette.turns.len(), 3); + assert_eq!(cassette.turns.len(), 4); let search_events = support::recorded_named_sse_events(&cassette.turns[0]); assert!( @@ -906,6 +1292,9 @@ fn openai_streaming_cassette_preserves_public_lifecycle_and_terminal_identity() let (loaded_item_id, loaded_call_id, loaded_arguments) = assert_observed_call_lifecycle(&cassette.turns[1]); assert_eq!(loaded_arguments, serde_json::json!({"city": "Paris"})); + let (namespace_item_id, namespace_call_id, namespace_arguments) = + assert_observed_call_lifecycle(&cassette.turns[2]); + assert_eq!(namespace_arguments, serde_json::json!({"city": "Paris"})); let responses = cassette.turns.iter().map(terminal_response).collect::>(); let terminal_search_call = responses[0]["output"] @@ -940,7 +1329,19 @@ fn openai_streaming_cassette_preserves_public_lifecycle_and_terminal_identity() assert_eq!(terminal_loaded_call["id"].as_str(), Some(loaded_item_id.as_str())); assert_eq!(terminal_loaded_call["call_id"].as_str(), Some(loaded_call_id.as_str())); - let final_events = support::recorded_named_sse_events(&cassette.turns[2]); + let terminal_namespace_call = responses[2]["output"] + .as_array() + .expect("turn three terminal output should be an array") + .iter() + .find(|item| item["type"] == "function_call" && item["namespace"] == "travel" && item["name"] == "get_timezone") + .expect("turn three terminal response should contain travel.get_timezone"); + assert_eq!(terminal_namespace_call["id"].as_str(), Some(namespace_item_id.as_str())); + assert_eq!( + terminal_namespace_call["call_id"].as_str(), + Some(namespace_call_id.as_str()) + ); + + let final_events = support::recorded_named_sse_events(&cassette.turns[3]); assert!( final_events .iter() @@ -951,17 +1352,14 @@ fn openai_streaming_cassette_preserves_public_lifecycle_and_terminal_identity() .map(|turn| turn.request.body.input.clone()) .collect::>(); let semantic = normalize_flow(&responses, &continuation_inputs, Projection::Public); - assert_eq!(semantic.loaded_function_name, "get_weather"); - assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); + assert_eq!(semantic.final_text.trim(), "PARIS_MIXED_TOOLS_OK"); } #[test] fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_mismatch() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/cassettes/tool_search") - .join(DIRECT_VLLM_STREAMING_CASSETTE); + let path = tool_search_cassette_directory().join(DIRECT_VLLM_STREAMING_CASSETTE); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); - assert_eq!(cassette.turns.len(), 3); + assert_eq!(cassette.turns.len(), 4); let (search_lifecycle_item_id, search_lifecycle_call_id, search_arguments) = assert_observed_call_lifecycle(&cassette.turns[0]); @@ -973,8 +1371,11 @@ fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_ let (loaded_lifecycle_item_id, loaded_lifecycle_call_id, loaded_arguments) = assert_observed_call_lifecycle(&cassette.turns[1]); assert_eq!(loaded_arguments, serde_json::json!({"city": "Paris"})); + let (namespace_lifecycle_item_id, namespace_lifecycle_call_id, namespace_arguments) = + assert_observed_call_lifecycle(&cassette.turns[2]); + assert_eq!(namespace_arguments, serde_json::json!({"city": "Paris"})); - let final_events = support::recorded_named_sse_events(&cassette.turns[2]); + let final_events = support::recorded_named_sse_events(&cassette.turns[3]); assert!( final_events .iter() @@ -987,7 +1388,7 @@ fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_ assert_eq!(final_completed.len(), 1); assert_eq!(final_completed[0]["response"]["status"], "completed"); let final_lifecycle = relevant_call_lifecycle_from_named_sse( - cassette.turns[2] + cassette.turns[3] .response .sse .as_ref() @@ -1013,6 +1414,12 @@ fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_ .iter() .find(|item| item["type"] == "function_call" && item["name"] == "get_weather") .expect("turn two terminal response should contain get_weather"); + let terminal_namespace_call = responses[2]["output"] + .as_array() + .expect("turn three terminal output should be an array") + .iter() + .find(|item| item["type"] == "function_call" && item["name"] == "agentic_ns__travel__get_timezone") + .expect("turn three terminal response should contain flattened travel.get_timezone"); assert_ne!( terminal_search_call["id"].as_str(), Some(search_lifecycle_item_id.as_str()), @@ -1033,14 +1440,23 @@ fn direct_vllm_streaming_cassette_characterizes_lifecycle_and_terminal_identity_ Some(loaded_lifecycle_call_id.as_str()), "observed vLLM stream regenerates the terminal loaded-function call ID" ); + assert_ne!( + terminal_namespace_call["id"].as_str(), + Some(namespace_lifecycle_item_id.as_str()), + "observed vLLM stream regenerates the terminal namespace-member item ID" + ); + assert_ne!( + terminal_namespace_call["call_id"].as_str(), + Some(namespace_lifecycle_call_id.as_str()), + "observed vLLM stream regenerates the terminal namespace-member call ID" + ); let continuation_inputs = cassette.turns[1..] .iter() .map(|turn| turn.request.body.input.clone()) .collect::>(); let semantic = normalize_flow(&responses, &continuation_inputs, Projection::Normalized); - assert_eq!(semantic.loaded_function_name, "get_weather"); - assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); + assert_eq!(semantic.final_text.trim(), "PARIS_MIXED_TOOLS_OK"); } const PROVIDER_PARITY_CASSETTES: [&str; 7] = [ @@ -1091,6 +1507,12 @@ fn assert_public_request_projection( ) .expect("OpenAI tool fixture should be valid JSON"); assert_eq!(request_bodies[0].get("tools"), Some(&expected_initial)); + let tool_choice_fixture = if filename.contains("openai-reference") { + "openai_tool_choice_sequence.json" + } else { + "gateway_tool_choice_sequence.json" + }; + assert_tool_choice_sequence(directory, tool_choice_fixture, request_bodies); if filename == GATEWAY_BLOCKING_CASSETTE { assert!( request_bodies @@ -1108,11 +1530,14 @@ fn assert_public_request_projection( .iter() .all(|body| body.get("store") == Some(&Value::Bool(true))) ); - assert_eq!(request_bodies[1].get("previous_response_id"), responses[0].get("id")); - assert_eq!(request_bodies[2].get("previous_response_id"), responses[1].get("id")); + for index in 1..request_bodies.len() { + assert_eq!( + request_bodies[index].get("previous_response_id"), + responses[index - 1].get("id") + ); + } } - assert!(!request_bodies[1].contains_key("tools")); - assert!(!request_bodies[2].contains_key("tools")); + assert!(request_bodies[1..].iter().all(|body| !body.contains_key("tools"))); } fn assert_normalized_request_projection( @@ -1131,8 +1556,12 @@ fn assert_normalized_request_projection( ) .expect("vLLM post-search tool fixture should be valid JSON"); assert_eq!(request_bodies[0].get("tools"), Some(&expected_initial)); - assert_eq!(request_bodies[1].get("tools"), Some(&expected_next)); - assert_eq!(request_bodies[2].get("tools"), Some(&expected_next)); + assert_tool_choice_sequence(directory, "vllm_tool_choice_sequence.json", request_bodies); + assert!( + request_bodies[1..] + .iter() + .all(|body| body.get("tools") == Some(&expected_next)) + ); assert!( request_bodies .iter() @@ -1176,6 +1605,30 @@ fn assert_normalized_request_projection( &inputs[2][..expected_third_prefix.len()], expected_third_prefix.as_slice() ); + let mut expected_fourth_prefix = inputs[2].clone(); + expected_fourth_prefix.extend( + responses[2]["output"] + .as_array() + .expect("turn three output should be an array") + .iter() + .cloned(), + ); + assert_eq!( + &inputs[3][..expected_fourth_prefix.len()], + expected_fourth_prefix.as_slice() + ); +} + +fn assert_tool_choice_sequence(directory: &Path, filename: &str, request_bodies: &[&serde_json::Map]) { + let expected = fixture_json(directory, filename); + let expected = expected + .as_array() + .expect("tool-choice sequence fixture should be an array"); + assert_eq!(request_bodies.len(), expected.len()); + for (body, choice) in request_bodies.iter().zip(expected) { + assert_eq!(body.get("tool_choice"), Some(choice)); + assert_eq!(body.get("parallel_tool_calls"), Some(&Value::Bool(false))); + } } fn assert_request_projection( @@ -1211,7 +1664,7 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow let raw_text = fs::read_to_string(&path).expect("characterization cassette should be readable"); let raw_document = serde_yaml::from_str::(&raw_text).expect("characterization YAML should be valid"); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); - assert_eq!(cassette.turns.len(), 3, "{filename} should contain three turns"); + assert_eq!(cassette.turns.len(), 4, "{filename} should contain four turns"); let responses = cassette.turns.iter().map(terminal_response).collect::>(); let inputs = cassette.turns[1..] .iter() @@ -1224,7 +1677,7 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow #[test] fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { - let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); + let directory = tool_search_cassette_directory(); let expected_tools = serde_json::from_str::( &fs::read_to_string(directory.join("returned_tools.json")).expect("returned tool fixture should be readable"), ) @@ -1242,10 +1695,14 @@ fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { "returned tool drift in {filename}" ); assert_eq!( - semantic.function_output, expected_outputs["get_weather"], - "client function-output drift in {filename}" + semantic.loaded_calls[0].function_output, expected_outputs["get_weather"], + "ordinary function-output drift in {filename}" + ); + assert_eq!( + semantic.loaded_calls[1].function_output, expected_outputs["get_timezone"], + "namespace-member function-output drift in {filename}" ); - assert_eq!(semantic.final_text.trim(), "PARIS_WEATHER_OK"); + assert_eq!(semantic.final_text.trim(), "PARIS_MIXED_TOOLS_OK"); if let Some(expected) = &reference { assert_eq!(&semantic, expected, "semantic provider drift in {filename}"); } else { diff --git a/crates/agentic-server-core/tests/tool_search_state_test.rs b/crates/agentic-server-core/tests/tool_search_state_test.rs index 63daaeae..8bc16be4 100644 --- a/crates/agentic-server-core/tests/tool_search_state_test.rs +++ b/crates/agentic-server-core/tests/tool_search_state_test.rs @@ -65,24 +65,26 @@ fn tool_values(tools: Option<&[agentic_core::ResponsesTool]>) -> Value { serde_json::to_value(tools).expect("prepared tools serialize") } -fn private_request(state: &ToolSearchState, public: &RequestPayload) -> RequestPayload { +fn private_request(state: &mut ToolSearchState, public: &RequestPayload) -> RequestPayload { + let mut private = public.clone(); state - .private_inference_request(public) - .expect("prepared state materializes a private inference request") + .prepare_inference_request(&mut private) + .expect("prepared state materializes a private inference request"); + private } -fn private_tool_values(state: &ToolSearchState, public: &RequestPayload) -> Value { +fn private_tool_values(state: &mut ToolSearchState, public: &RequestPayload) -> Value { let private = private_request(state, public); tool_values(private.tools.as_deref()) } -fn private_input_value(state: &ToolSearchState, public: &RequestPayload) -> Value { +fn private_input_value(state: &mut ToolSearchState, public: &RequestPayload) -> Value { serde_json::to_value(private_request(state, public).input).expect("private input serializes") } fn synthetic_description(state: &ToolSearchState) -> &str { state - .synthetic_function() + .synthetic_tool_search() .and_then(|function| function.description.as_deref()) .expect("active tool search has a synthetic description") } @@ -109,23 +111,25 @@ fn fresh_and_sequential_state_has_distinct_deterministic_views() { ]); let request = request(tools, input); - let state = ToolSearchState::build(&request).expect("valid ordered history"); - let rebuilt = ToolSearchState::build(&request).expect("same request builds again"); + let mut state = ToolSearchState::build(&request).expect("valid ordered history"); + let mut rebuilt = ToolSearchState::build(&request).expect("same request builds again"); + let private = private_tool_values(&mut state, &request); + let rebuilt_private = private_tool_values(&mut rebuilt, &request); assert!(state.is_active()); assert_eq!( serde_json::to_string(&( tool_values(state.public_effective_tools()), - private_tool_values(&state, &request), + &private, tool_values(Some(state.loaded_public_tools())), - serde_json::to_value(state.synthetic_function()).expect("synthetic declaration serializes") + serde_json::to_value(state.synthetic_tool_search()).expect("synthetic declaration serializes") )) .expect("state snapshot serializes"), serde_json::to_string(&( tool_values(rebuilt.public_effective_tools()), - private_tool_values(&rebuilt, &request), + &rebuilt_private, tool_values(Some(rebuilt.loaded_public_tools())), - serde_json::to_value(rebuilt.synthetic_function()).expect("synthetic declaration serializes") + serde_json::to_value(rebuilt.synthetic_tool_search()).expect("synthetic declaration serializes") )) .expect("rebuilt snapshot serializes") ); @@ -141,10 +145,9 @@ fn fresh_and_sequential_state_has_distinct_deterministic_views() { let loaded = tool_values(Some(state.loaded_public_tools())); assert_eq!(loaded, json!([dynamic]), "an exact repeated definition is idempotent"); - let private = private_tool_values(&state, &request); assert_eq!(private.as_array().map(Vec::len), Some(3)); - assert_eq!(private[0]["type"], "function"); - assert_eq!(private[0]["name"], "tool_search"); + assert_eq!(private[0]["type"], "tool_search"); + assert_eq!(private[0]["execution"], "client"); assert_eq!(private[1]["name"], "current_time"); assert_eq!(private[2]["name"], "get_uv"); assert!(private[2].get("defer_loading").is_none()); @@ -158,22 +161,128 @@ fn fresh_and_sequential_state_has_distinct_deterministic_views() { ); assert_eq!( - serde_json::to_value(state.synthetic_function()).expect("synthetic declaration serializes"), + serde_json::to_value(state.synthetic_tool_search()).expect("synthetic declaration serializes"), json!({ - "type": "function", - "name": "tool_search", + "execution": "client", "description": "Search the client tool catalog. Available catalog entry: get_weather — Get weather.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": false - }, - "strict": true + } }) ); } +#[test] +fn optional_declaration_fields_get_private_defaults_and_supplied_values_are_preserved() { + let default_parameters = json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "A concise description of the needed capabilities." + } + }, + "required": ["query"], + "additionalProperties": false + }); + let minimal = request( + json!([{"type": "tool_search", "execution": "client"}]), + json!("find a weather tool"), + ); + let minimal_state = ToolSearchState::build(&minimal).expect("minimal declaration reaches state preparation"); + let minimal_synthetic = serde_json::to_value(minimal_state.synthetic_tool_search()).expect("synthetic serializes"); + assert_eq!(minimal_synthetic["description"], "Search the client tool catalog"); + assert_eq!(minimal_synthetic["parameters"], default_parameters); + + let supplied_declaration = search_declaration(); + let supplied = request(json!([supplied_declaration.clone()]), json!("find a weather tool")); + let supplied_state = ToolSearchState::build(&supplied).expect("supplied declaration reaches state preparation"); + let supplied_synthetic = + serde_json::to_value(supplied_state.synthetic_tool_search()).expect("synthetic serializes"); + assert_eq!(supplied_synthetic["description"], supplied_declaration["description"]); + assert_eq!(supplied_synthetic["parameters"], supplied_declaration["parameters"]); + + let supplied_parameters = json!({"type": "object", "properties": {"term": {"type": "string"}}}); + let blank_description = request( + json!([{ + "type": "tool_search", + "execution": "client", + "description": " ", + "parameters": supplied_parameters.clone() + }]), + json!("find a weather tool"), + ); + let state = ToolSearchState::build(&blank_description).expect("blank description is normalized privately"); + let synthetic = serde_json::to_value(state.synthetic_tool_search()).expect("synthetic serializes"); + assert_eq!(synthetic["description"], "Search the client tool catalog"); + assert_eq!(synthetic["parameters"], supplied_parameters); + + for invalid_parameters in [json!({}), json!({"type": "array"})] { + let invalid_schema = request( + json!([{ + "type": "tool_search", + "execution": "client", + "description": "Find exactly the needed tool", + "parameters": invalid_parameters + }]), + json!("find a weather tool"), + ); + let state = ToolSearchState::build(&invalid_schema).expect("invalid schema is normalized privately"); + let synthetic = serde_json::to_value(state.synthetic_tool_search()).expect("synthetic serializes"); + assert_eq!(synthetic["description"], "Find exactly the needed tool"); + assert_eq!(synthetic["parameters"], default_parameters); + } +} + +#[test] +fn only_completed_search_outputs_load_definitions() { + let deferred = function("get_weather", "Get weather", "string", true); + + for status in ["in_progress", "incomplete"] { + let mut output = search_output("call_search_1", vec![deferred.clone()]); + output["status"] = Value::String(status.to_owned()); + let public = request( + json!([search_declaration(), deferred.clone()]), + json!([search_call("call_search_1"), output]), + ); + let error = ToolSearchState::build(&public).expect_err("a non-completed output must not load definitions"); + assert!( + error + .to_string() + .contains("must be completed before it may load tool definitions") + ); + } + + let mut output = search_output("call_search_1", vec![deferred.clone()]); + output["status"] = json!("completed"); + let completed = request( + json!([search_declaration(), deferred.clone()]), + json!([search_call("call_search_1"), output]), + ); + let state = ToolSearchState::build(&completed).expect("a completed output loads definitions"); + assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([deferred])); +} + +#[test] +fn replayed_search_calls_accept_documented_statuses() { + let deferred = function("get_weather", "Get weather", "string", true); + + for status in ["in_progress", "completed", "incomplete"] { + let mut call = search_call("call_search_1"); + call["status"] = Value::String(status.to_owned()); + let public = request( + json!([search_declaration(), deferred.clone()]), + json!([call, search_output("call_search_1", vec![deferred.clone()])]), + ); + + let state = ToolSearchState::build(&public).expect("documented replayed call status is accepted"); + assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([deferred])); + } +} + #[test] fn one_history_pass_prepares_canonical_private_input_without_mutating_public_input() { let returned = function("get_weather", "Get weather", "string", true); @@ -192,7 +301,7 @@ fn one_history_pass_prepares_canonical_private_input_without_mutating_public_inp ); let public_before = serde_json::to_value(&request.input).expect("public input serializes"); - let state = ToolSearchState::build(&request).expect("matching history prepares once"); + let mut state = ToolSearchState::build(&request).expect("matching history prepares once"); assert_eq!( serde_json::to_value(&request.input).expect("public input remains serializable"), @@ -200,7 +309,7 @@ fn one_history_pass_prepares_canonical_private_input_without_mutating_public_inp "the public request must not be rewritten" ); assert_eq!( - private_input_value(&state, &request), + private_input_value(&mut state, &request), json!([ {"type": "message", "role": "user", "content": "find weather"}, { @@ -220,24 +329,6 @@ fn one_history_pass_prepares_canonical_private_input_without_mutating_public_inp ); } -#[test] -fn ordinary_request_builds_inactive_state_without_loaded_tools() { - let request = request( - json!([{ - "type": "function", - "name": "ordinary", - "parameters": {"type": "object", "properties": {"secret_schema": {"type": "string"}}} - }]), - json!("hello"), - ); - - let state = ToolSearchState::build(&request).expect("ordinary request prepares inactive state"); - - assert!(!state.is_active()); - assert!(state.public_effective_tools().is_none()); - assert!(state.loaded_public_tools().is_empty()); -} - #[test] fn invalid_history_order_and_linkage_are_rejected() { let deferred = function("get_weather", "Get weather", "string", true); @@ -339,7 +430,6 @@ fn invalid_loaded_definitions_and_normalized_collisions_are_rejected() { vec![json!({ "type": "mcp", "server_label": "get_weather", - "server_description": "A different kind", "server_url": "https://mcp.example.test/mcp", "defer_loading": true })] @@ -402,204 +492,37 @@ fn invalid_loaded_definitions_and_normalized_collisions_are_rejected() { } #[test] -fn initial_and_dynamic_model_visible_declared_name_collisions_are_rejected() { - let initial_cases = [ - json!([ - search_declaration(), - {"type": "function", "name": "shared"}, - {"type": "custom", "name": "shared"} - ]), - json!([ - search_declaration(), - {"type": "function", "name": "web_search"}, - {"type": "web_search_preview"} - ]), - json!([ - search_declaration(), - {"type": "function", "name": "file_search"}, - {"type": "file_search", "vector_store_ids": []} - ]), - json!([ - search_declaration(), - {"type": "function", "name": "code_interpreter"}, - {"type": "code_interpreter"} - ]), - json!([ - search_declaration(), - {"type": "custom", "name": "agentic_ns__weather__forecast"}, - { - "type": "namespace", - "name": "weather", - "tools": [{"type": "function", "name": "forecast"}] - } - ]), - json!([ - search_declaration(), - {"type": "namespace", "name": "a__b", "tools": [{"type": "function", "name": "c"}]}, - {"type": "namespace", "name": "a", "tools": [{"type": "function", "name": "b__c"}]} - ]), - ]; - for tools in initial_cases { - assert!( - ToolSearchState::build(&request(tools, json!("find a tool"))).is_err(), - "initial normalized collisions must fail" - ); - } - - let dynamic_cases = [ - ( - json!([search_declaration(), {"type": "custom", "name": "shared"}]), - function("shared", "Dynamic function", "string", true), - ), - ( - json!([search_declaration(), {"type": "web_search_preview"}]), - function("web_search", "Dynamic function", "string", true), - ), - ( - json!([search_declaration(), {"type": "file_search", "vector_store_ids": []}]), - function("file_search", "Dynamic function", "string", true), - ), - ( - json!([search_declaration(), {"type": "code_interpreter"}]), - function("code_interpreter", "Dynamic function", "string", true), - ), - ( - json!([{ - "type": "namespace", - "name": "weather", - "tools": [{"type": "function", "name": "forecast"}] - }, search_declaration()]), - function("agentic_ns__weather__forecast", "Dynamic function", "string", true), - ), - ]; - for (tools, loaded) in dynamic_cases { - let input = json!([ - search_call("call_search_1"), - search_output("call_search_1", vec![loaded]) - ]); - assert!( - ToolSearchState::build(&request(tools, input)).is_err(), - "dynamic normalized collisions must fail" - ); - } -} - -#[test] -fn canonical_mcp_equality_is_private_and_secret_sensitive() { - let mcp = json!({ - "type": "mcp", - "server_label": "weather_mcp", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/private-path", - "headers": {"X-Private-Token": "header-secret-a"}, - "authorization": "authorization-secret-a", - "defer_loading": true - }); - let matching = request( - json!([search_declaration(), mcp.clone()]), - json!([ - search_call("call_search_1"), - search_output("call_search_1", vec![mcp.clone()]) - ]), - ); - let state = ToolSearchState::build(&matching).expect("an exact secret-bearing definition is idempotent"); - let debug = format!("{state:?}"); - for secret in ["private-path", "header-secret-a", "authorization-secret-a"] { - assert!(!debug.contains(secret), "state Debug leaked {secret}"); - } - - let mut conflicting = mcp; - conflicting["authorization"] = json!("authorization-secret-b"); - let conflict = request( - matching.tools.map_or_else( - || json!([]), - |tools| serde_json::to_value(tools).expect("tools serialize"), - ), - json!([ - search_call("call_search_2"), - search_output("call_search_2", vec![conflicting]) - ]), +fn initial_and_dynamic_namespace_collisions_are_rejected() { + let initial = json!([ + search_declaration(), + {"type": "namespace", "name": "a__b", "tools": [{"type": "function", "name": "c"}]}, + {"type": "namespace", "name": "a", "tools": [{"type": "function", "name": "b__c"}]} + ]); + assert!( + ToolSearchState::build(&request(initial, json!("find a tool"))).is_err(), + "initial namespace collisions must fail" ); - let error = ToolSearchState::build(&conflict).expect_err("credential changes are configuration conflicts"); - let message = error.to_string(); - for secret in [ - "private-path", - "header-secret-a", - "authorization-secret-a", - "authorization-secret-b", - ] { - assert!(!message.contains(secret), "conflict error leaked {secret}"); - } -} -#[test] -fn loaded_mcp_model_output_projection_excludes_all_execution_configuration() { - let url_mcp = json!({ - "type": "mcp", - "server_label": "weather_url", - "server_description": "Weather URL tools", - "server_url": "https://url-user:url-password@mcp.example.test/private-path?X-Amz-Credential=query-credential&sig=query-signature", - "headers": { - "Authorization": "Bearer header-authorization", - "X-Private-Token": "header-private-token" - }, - "authorization": "top-level-authorization", - "allowed_tools": ["allowed-tool-sentinel"], - "require_approval": "approval-sentinel", - "defer_loading": true - }); - let connector_mcp = json!({ - "type": "mcp", - "server_label": "weather_connector", - "server_description": "Weather connector tools", - "connector_id": "connector-id-sentinel", - "defer_loading": true - }); - let public = request( - json!([search_declaration()]), - json!([ - search_call("call_search_url"), - search_output("call_search_url", vec![url_mcp]), - search_call("call_search_connector"), - search_output("call_search_connector", vec![connector_mcp]) - ]), - ); - let state = ToolSearchState::build(&public) - .expect("sensitive execution configuration remains valid private equality state"); - - let private_value = private_input_value(&state, &public); - let private_input = private_value.to_string(); - for forbidden in [ - "server_url", - "url-user", - "url-password", - "private-path", - "X-Amz-Credential", - "query-credential", - "query-signature", - "headers", - "header-authorization", - "header-private-token", - "authorization", - "top-level-authorization", - "connector_id", - "connector-id-sentinel", - "allowed_tools", - "allowed-tool-sentinel", - "require_approval", - "approval-sentinel", - "defer_loading", - ] { - assert!(!private_input.contains(forbidden), "private input leaked {forbidden}"); - } - assert_eq!( - [private_value[1]["output"].as_str(), private_value[3]["output"].as_str()], - [ - Some(r#"{"tools":[{"server_description":"Weather URL tools","server_label":"weather_url","type":"mcp"}]}"#), - Some( - r#"{"tools":[{"server_description":"Weather connector tools","server_label":"weather_connector","type":"mcp"}]}"# - ), - ] + let tools = json!([{ + "type": "namespace", + "name": "weather", + "tools": [{"type": "function", "name": "forecast"}] + }, search_declaration()]); + let input = json!([ + search_call("call_search_1"), + search_output( + "call_search_1", + vec![function( + "agentic_ns__weather__forecast", + "Dynamic function", + "string", + true + )] + ) + ]); + assert!( + ToolSearchState::build(&request(tools, input)).is_err(), + "dynamic namespace collisions must fail" ); } @@ -628,15 +551,15 @@ fn loaded_namespace_model_output_is_identity_only_while_private_tools_retain_mem search_output("call_search_namespace", vec![namespace]) ]), ); - let state = ToolSearchState::build(&public).expect("loaded namespace prepares without transport behavior"); + let mut state = ToolSearchState::build(&public).expect("loaded namespace prepares without transport behavior"); - let private_input = private_input_value(&state, &public); + let private = private_request(&mut state, &public); + let private_input = serde_json::to_value(&private.input).expect("private input serializes"); assert_eq!( private_input[1]["output"], r#"{"tools":[{"description":"Weather tools","name":"weather","type":"namespace"}]}"# ); - let private_tools = - serde_json::to_string(&private_request(&state, &public).tools).expect("private tools serialize"); + let private_tools = serde_json::to_string(&private.tools).expect("private tools serialize"); assert!( private_tools.contains("forecast"), "loaded member must remain available to request lowering" @@ -695,7 +618,7 @@ fn namespace_partial_load_merges_members_and_is_idempotent() { search_output("call_namespace_2", vec![loaded_subset.clone()]) ]), ); - let state = + let mut state = ToolSearchState::build(&public_request).expect("same-name partial namespace output merges exact members"); let public = tool_values(state.public_effective_tools()); @@ -705,7 +628,7 @@ fn namespace_partial_load_merges_members_and_is_idempotent() { ); let loaded = tool_values(Some(state.loaded_public_tools())); assert_eq!(loaded, json!([loaded_subset]), "exact member reload is idempotent"); - let private = private_tool_values(&state, &public_request); + let private = private_tool_values(&mut state, &public_request); assert_eq!(private[1]["tools"].as_array().map(Vec::len), Some(2)); assert!(private[1]["tools"][0].get("defer_loading").is_none()); assert!(private[1]["tools"][1].get("defer_loading").is_none()); @@ -834,9 +757,10 @@ fn namespace_tool_choice_rejects_withheld_member_and_accepts_loaded_member() { serde_json::from_value(json!({"type": "function", "namespace": "weather", "name": "forecast"})) .expect("namespaced choice"), ); - let state = ToolSearchState::build(&withheld).expect("state preparation succeeds before readiness check"); + let mut state = ToolSearchState::build(&withheld).expect("state preparation succeeds before readiness check"); + let mut private = withheld.clone(); let error = state - .private_inference_request(&withheld) + .prepare_inference_request(&mut private) .expect_err("withheld namespace member cannot be forced"); assert!(error.to_string().contains("before its definition is loaded")); @@ -848,10 +772,8 @@ fn namespace_tool_choice_rejects_withheld_member_and_accepts_loaded_member() { ]), ); loaded.tool_choice.clone_from(&withheld.tool_choice); - let state = ToolSearchState::build(&loaded).expect("exact namespace member loads"); - let private = state - .private_inference_request(&loaded) - .expect("loaded member may be selected"); + let mut state = ToolSearchState::build(&loaded).expect("exact namespace member loads"); + let private = private_request(&mut state, &loaded); let upstream = serde_json::to_value(private.to_upstream_request(false).expect("private request lowers")) .expect("upstream request serializes"); assert_eq!( @@ -906,10 +828,8 @@ fn namespace_history_rejects_exact_withheld_calls_and_lowers_loaded_calls() { loaded_call ]), ); - let state = ToolSearchState::build(&loaded_request).expect("loaded known history call remains valid"); - let private = state - .private_inference_request(&loaded_request) - .expect("loaded namespace request prepares"); + let mut state = ToolSearchState::build(&loaded_request).expect("loaded known history call remains valid"); + let private = private_request(&mut state, &loaded_request); let upstream = serde_json::to_value(private.to_upstream_request(false).expect("loaded history lowers")) .expect("upstream request serializes"); assert_eq!(upstream["input"][2]["name"], flat_name); @@ -1002,9 +922,10 @@ fn top_level_function_tool_choices_require_the_definition_to_be_loaded() { ] { let mut withheld = request(json!([search_declaration(), deferred.clone()]), json!("find weather")); withheld.tool_choice = Some(serde_json::from_value(choice).expect("function tool choice")); - let state = ToolSearchState::build(&withheld).expect("state preparation succeeds before choice validation"); + let mut state = ToolSearchState::build(&withheld).expect("state preparation succeeds before choice validation"); + let mut private = withheld.clone(); state - .private_inference_request(&withheld) + .prepare_inference_request(&mut private) .expect_err("a withheld function cannot be selected"); } @@ -1017,10 +938,8 @@ fn top_level_function_tool_choices_require_the_definition_to_be_loaded() { ); loaded.tool_choice = Some(serde_json::from_value(json!({"type": "function", "name": "get_weather"})).expect("function tool choice")); - let state = ToolSearchState::build(&loaded).expect("loaded state"); - state - .private_inference_request(&loaded) - .expect("a loaded function may be selected"); + let mut state = ToolSearchState::build(&loaded).expect("loaded state"); + private_request(&mut state, &loaded); } #[test] @@ -1043,10 +962,10 @@ fn dynamically_returned_namespace_members_start_loaded_without_catalog_debt() { search_output("call_dynamic_namespace", vec![dynamic_namespace]) ]), ); - let state = ToolSearchState::build(&public).expect("dynamically returned namespace members are already loaded"); + let mut state = ToolSearchState::build(&public).expect("dynamically returned namespace members are already loaded"); assert_eq!(synthetic_description(&state), "Search the client tool catalog"); - let private = private_tool_values(&state, &public); + let private = private_tool_values(&mut state, &public); assert_eq!(private[1]["tools"].as_array().map(Vec::len), Some(1)); assert!(private[1]["tools"][0].get("defer_loading").is_none()); } @@ -1094,10 +1013,8 @@ fn dynamic_namespace_history_rejects_forward_references_but_accepts_valid_order_ call ]), ); - let state = ToolSearchState::build(&valid).expect("output-before-call order is valid"); - let private = state - .private_inference_request(&valid) - .expect("valid ordered request prepares"); + let mut state = ToolSearchState::build(&valid).expect("output-before-call order is valid"); + let private = private_request(&mut state, &valid); let upstream = serde_json::to_value(private.to_upstream_request(false).expect("valid history lowers")) .expect("upstream request serializes"); assert_eq!(upstream["input"][2]["name"], flat_name); @@ -1142,20 +1059,20 @@ fn declaration_free_manual_replay_builds_loaded_views_without_a_synthetic_declar search_output("call_search_1", vec![dynamic.clone()]) ]), ); - let state = + let mut state = ToolSearchState::build(&public_request).expect("manual public replay is valid without redeclaring tool_search"); assert!(state.is_active()); - assert!(state.synthetic_function().is_none()); + assert!(state.synthetic_tool_search().is_none()); assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([dynamic])); let public = tool_values(state.public_effective_tools()); - let private = private_tool_values(&state, &public_request); + let private = private_tool_values(&mut state, &public_request); assert_eq!(public[0]["defer_loading"], true); assert!(private[0].get("defer_loading").is_none()); } #[test] -fn private_inference_request_consumes_prepared_views_without_mutating_public_state() { +fn prepare_inference_request_consumes_prepared_views_without_mutating_public_source() { let deferred = function("get_weather", "Get weather", "string", true); let public = request( json!([search_declaration(), deferred.clone()]), @@ -1166,29 +1083,33 @@ fn private_inference_request_consumes_prepared_views_without_mutating_public_sta ]), ); let public_before = serde_json::to_value(&public).expect("public request serializes"); - let state = ToolSearchState::build(&public).expect("valid function-only state"); + let mut state = ToolSearchState::build(&public).expect("valid function-only state"); assert_eq!(tool_values(Some(state.loaded_public_tools())), json!([deferred])); assert_eq!(synthetic_description(&state), "Search the client tool catalog"); - let private = state - .private_inference_request(&public) - .expect("private inference request consumes the prepared private view"); + let private = private_request(&mut state, &public); assert_eq!( serde_json::to_value(&public).expect("public request still serializes"), public_before, "private lowering must not mutate the public request" ); - let private_value = serde_json::to_value(private).expect("private request serializes"); + let private_value = serde_json::to_value(&private).expect("private request serializes"); assert_eq!(private_value["input"][1]["type"], "function_call"); assert_eq!(private_value["input"][1]["name"], "tool_search"); assert_eq!(private_value["input"][1]["call_id"], "call_search_1"); assert_eq!(private_value["input"][2]["type"], "function_call_output"); assert_eq!(private_value["input"][2]["call_id"], "call_search_1"); assert_eq!(private_value["tools"].as_array().map(Vec::len), Some(2)); - assert_eq!(private_value["tools"][0]["name"], "tool_search"); + assert_eq!(private_value["tools"][0]["type"], "tool_search"); + assert_eq!(private_value["tools"][0]["execution"], "client"); assert_eq!(private_value["tools"][1]["name"], "get_weather"); assert!(private_value["tools"][1].get("defer_loading").is_none()); + + let upstream = serde_json::to_value(private.to_upstream_request(false).expect("prepared request lowers")) + .expect("upstream request serializes"); + assert_eq!(upstream["tools"][0]["type"], "function"); + assert_eq!(upstream["tools"][0]["name"], "tool_search"); } #[test] @@ -1217,65 +1138,28 @@ fn safe_catalog_is_minimal_and_never_exposes_deferred_configuration() { "parameters": {"type": "object", "properties": {"secret": {"type": "string"}}}, "defer_loading": true }] - }, - { - "type": "mcp", - "server_label": "hidden_mcp", - "server_description": "Safe MCP description", - "server_url": "https://mcp.example.test/secret-path", - "headers": {"Authorization": "Bearer catalog-secret"}, - "authorization": "catalog-authorization-secret", - "allowed_tools": ["secret_discovered_tool"], - "require_approval": "never", - "defer_loading": true - }, - { - "type": "mcp", - "server_label": "hidden_connector", - "server_description": "Safe connector description", - "connector_id": "connector-secret", - "defer_loading": true } ]), json!("find a tool"), ); let public_before = serde_json::to_value(&request).expect("public request serializes"); - let state = ToolSearchState::build(&request).expect("catalog construction is pure and needs no MCP connection"); - let private = private_request(&state, &request); + let mut state = ToolSearchState::build(&request).expect("catalog construction is pure"); + drop(private_request(&mut state, &request)); assert_eq!( serde_json::to_value(&request).expect("public request still serializes"), public_before, "private materialization must preserve public deferred configuration" ); - assert!( - private - .tools - .as_deref() - .expect("private tools") - .iter() - .all(|tool| !matches!(tool, agentic_core::ResponsesTool::Mcp(_))), - "deferred MCP entries must remain outside the private inference request" - ); assert_eq!( synthetic_description(&state), "Search the client tool catalog. Available catalog entries: hidden_function — Safe function description; \ -hidden_namespace — Safe namespace description; hidden_mcp — Safe MCP description; hidden_connector — Safe connector \ -description." +hidden_namespace — Safe namespace description." ); - let model_visible = serde_json::to_string(&state.synthetic_function()).expect("synthetic declaration serializes"); - for secret in [ - "secret_parameter", - "secret_member", - "secret member description", - "secret-path", - "catalog-secret", - "catalog-authorization-secret", - "secret_discovered_tool", - "connector-secret", - "require_approval", - ] { + let model_visible = + serde_json::to_string(&state.synthetic_tool_search()).expect("synthetic declaration serializes"); + for secret in ["secret_parameter", "secret_member", "secret member description"] { assert!(!model_visible.contains(secret), "catalog leaked {secret}"); } } @@ -1305,12 +1189,12 @@ fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { }])) .expect("valid restored loaded definitions"); - let state = ToolSearchState::build_with_loaded_tools(&request, &restored, false) + let mut state = ToolSearchState::build_with_loaded_tools(&request, &restored, false) .expect("typed metadata restores compaction-lost loaded state"); assert_eq!(state.loaded_public_tools().len(), 1); assert_eq!(synthetic_description(&state), "Search the client tool catalog"); - let private_request = private_request(&state, &request); + let private_request = private_request(&mut state, &request); let private = private_request.tools.as_deref().expect("private tools"); let loaded = private .iter() @@ -1368,7 +1252,7 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { }])) .expect("stored loaded definition"); - let state = ToolSearchState::build_with_loaded_tools(&request, &restored, true) + let mut state = ToolSearchState::build_with_loaded_tools(&request, &restored, true) .expect("explicit tools define the post-compaction catalog"); assert!(state.loaded_public_tools().is_empty()); assert!(state @@ -1376,7 +1260,7 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { .unwrap() .iter() .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); - assert!(private_request(&state, &request) + assert!(private_request(&mut state, &request) .tools .as_deref() .expect("private tools") @@ -1384,111 +1268,6 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); } -#[test] -fn replayed_mcp_loaded_marker_accepts_reprovided_credentials_but_rejects_public_config_change() { - let base_request = request( - json!([ - search_declaration(), - { - "type": "mcp", - "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "headers": {"X-API-Key": "fresh-secret"}, - "authorization": "fresh-authorization", - "require_approval": "never", - "defer_loading": true - } - ]), - json!([{ - "type": "compaction", - "encrypted_content": "Weather MCP was loaded earlier." - }]), - ); - let restored: Vec = serde_json::from_value(json!([{ - "type": "mcp", - "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "require_approval": "never", - "defer_loading": true - }])) - .expect("sanitized persisted MCP marker"); - - let state = ToolSearchState::build_with_loaded_tools(&base_request, &restored, true) - .expect("reprovided credentials do not conflict with sanitized persisted state"); - let private_request = private_request(&state, &base_request); - let loaded = private_request - .tools - .as_deref() - .expect("private tools") - .iter() - .find_map(|tool| match tool { - agentic_core::types::tools::ResponsesTool::Mcp(mcp) => Some(mcp), - _ => None, - }) - .expect("loaded MCP remains effective"); - assert_eq!( - loaded - .headers - .as_ref() - .and_then(|headers| headers.get("X-API-Key")) - .map(String::as_str), - Some("fresh-secret") - ); - assert_eq!(loaded.authorization.as_deref(), Some("fresh-authorization")); - assert_eq!(loaded.defer_loading, None); - - let persisted_history_request = request( - json!([ - search_declaration(), - { - "type": "mcp", - "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "headers": {"X-API-Key": "fresh-secret"}, - "authorization": "fresh-authorization", - "require_approval": "never", - "defer_loading": true - } - ]), - json!([ - { - "type": "tool_search_call", - "id": "tsc_stored_mcp", - "call_id": "call_stored_mcp", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_stored_mcp", - "tools": [{ - "type": "mcp", - "server_label": "weather", - "server_description": "Weather tools", - "server_url": "https://mcp.example.test/mcp", - "require_approval": "never", - "defer_loading": true - }] - } - ]), - ); - ToolSearchState::build_with_loaded_tools(&persisted_history_request, &restored, true) - .expect("trusted restored MCP state reconciles its sanitized persisted output"); - assert!( - ToolSearchState::build(&persisted_history_request).is_err(), - "fresh untrusted sanitized output must not weaken credential-sensitive equality" - ); - - let mut changed = restored; - let agentic_core::types::tools::ResponsesTool::Mcp(changed_mcp) = &mut changed[0] else { - panic!("MCP marker") - }; - changed_mcp.server_url = Some("https://mcp.example.test/changed".to_owned()); - assert!(ToolSearchState::build_with_loaded_tools(&base_request, &changed, true).is_err()); -} - #[test] fn replayed_loaded_marker_rejects_explicit_cross_kind_identity_collision() { let request = request( diff --git a/crates/agentic-server-core/tests/tool_search_test.rs b/crates/agentic-server-core/tests/tool_search_test.rs index 144b6a2f..2bb5e0cd 100644 --- a/crates/agentic-server-core/tests/tool_search_test.rs +++ b/crates/agentic-server-core/tests/tool_search_test.rs @@ -27,13 +27,6 @@ use serde_json::{Value, json}; use tokio::net::TcpListener; use tokio::sync::Mutex; -#[derive(Default)] -struct McpRequestCounts { - initialize: AtomicUsize, - list_tools: AtomicUsize, - call_tool: AtomicUsize, -} - #[derive(Debug)] struct CountingWebSearch { calls: Arc, @@ -234,21 +227,6 @@ fn streaming_partial_search_failure_response() -> String { streaming_response(events) } -fn streaming_pending_call_overflow_response() -> String { - let mut events = vec![json!({ - "type": "response.created", - "response": {"id": "upstream_pending_overflow", "status": "in_progress"} - })]; - events.extend((0..=128).map(|output_index| { - json!({ - "type": "response.output_item.added", "output_index": output_index, - "item": {"id": format!("fc_pending_{output_index}"), "type": "function_call", - "call_id": format!("call_pending_{output_index}"), "arguments": "", "status": "in_progress"} - }) - })); - streaming_response(events) -} - fn streaming_gateway_call_then_malformed_search_response() -> String { let events = [ json!({"type":"response.created","response":{"id":"up_mixed","status":"in_progress"}}), @@ -352,84 +330,6 @@ async fn run_streaming(request: RequestPayload, context: Arc) events } -async fn spawn_counting_mcp() -> (String, Arc, tokio::task::JoinHandle<()>) { - let counts = Arc::new(McpRequestCounts::default()); - let route_counts = Arc::clone(&counts); - let app = Router::new().route( - "/mcp", - post(move |body: Bytes| { - let route_counts = Arc::clone(&route_counts); - async move { - let request: Value = serde_json::from_slice(&body).expect("MCP request JSON"); - let method = request["method"].as_str().unwrap_or_default(); - let id = request.get("id").cloned().unwrap_or(Value::Null); - let response = match method { - "initialize" => { - route_counts.initialize.fetch_add(1, Ordering::SeqCst); - Some(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "protocolVersion": "2025-06-18", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "counting-mcp", "version": "1.0.0"} - } - })) - } - "notifications/initialized" => None, - "tools/list" => { - route_counts.list_tools.fetch_add(1, Ordering::SeqCst); - Some(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "tools": [{ - "name": "forecast", - "description": "Return a forecast", - "inputSchema": { - "type": "object", - "properties": {"city": {"type": "string"}} - } - }] - } - })) - } - "tools/call" => { - route_counts.call_tool.fetch_add(1, Ordering::SeqCst); - Some(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [{"type": "text", "text": "sunny"}], - "isError": false - } - })) - } - other => panic!("unexpected MCP method {other}"), - }; - - match response { - Some(response) => axum::response::Response::builder() - .status(200) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(response.to_string())) - .expect("MCP response") - .into_response(), - None => axum::response::Response::builder() - .status(202) - .body(axum::body::Body::empty()) - .expect("MCP notification response") - .into_response(), - } - } - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind MCP server"); - let address = listener.local_addr().expect("MCP address"); - let handle = tokio::spawn(async move { axum::serve(listener, app).await.expect("MCP server") }); - (format!("http://{address}/mcp"), counts, handle) -} - fn request(input: &Value, tools: &Value) -> RequestPayload { serde_json::from_value(json!({ "model": "test", @@ -485,27 +385,6 @@ fn assert_private_request_sequence(requests: &[Value]) { } } -fn assert_mcp_private_request_sequence(requests: &[Value], mcp_url: &str) { - assert_eq!(requests.len(), 3); - let initial_tool_names = requests[0]["tools"] - .as_array() - .expect("initial private tools") - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - assert_eq!(initial_tool_names, ["tool_search"]); - assert!(!requests[0].to_string().contains(mcp_url)); - for request in &requests[1..] { - let tools = request["tools"].as_array().expect("private tools"); - let tool_names = tools - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - assert_eq!(tool_names, ["tool_search", "mcp__weather__forecast"]); - assert!(tools.iter().all(|tool| tool.get("defer_loading").is_none())); - } -} - fn search_declaration() -> Value { json!({ "type": "tool_search", @@ -534,18 +413,6 @@ fn deferred_weather() -> Value { }) } -fn deferred_weather_mcp(server_url: &str) -> Value { - json!({ - "type": "mcp", - "server_label": "weather", - "server_description": "Weather server", - "server_url": server_url, - "allowed_tools": ["forecast"], - "require_approval": "never", - "defer_loading": true - }) -} - fn completed_search_replay() -> Value { json!([ { @@ -819,38 +686,6 @@ async fn upstream_failure_after_partial_search_preserves_provider_error_without_ })); } -#[tokio::test] -async fn search_active_pending_call_overflow_uses_standard_response_failed() { - let (llm_url, _requests, _server) = - spawn_sequenced_streaming_llm(vec![streaming_pending_call_overflow_response()]).await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let mut payload = request( - &json!("find weather"), - &json!([search_declaration(), deferred_weather()]), - ); - payload.stream = true; - - let events = run_streaming(payload, context).await; - - let failed = events.last().expect("response.failed"); - assert_eq!(failed["type"], "response.failed"); - assert_eq!(failed["response"]["error"]["code"], "tool_error"); - assert!(events.iter().all(|event| event["type"] != "error")); - assert!(events.iter().all(|event| event["type"] != "response.completed")); - assert_eq!( - events - .iter() - .map(|event| event["sequence_number"].as_u64()) - .collect::>(), - (0..u64::try_from(events.len()).unwrap()).map(Some).collect::>() - ); -} - #[tokio::test] async fn malformed_streaming_search_is_not_dispatched_or_persisted_after_start() { let (llm_url, _requests, _server) = @@ -989,326 +824,6 @@ async fn function_only_nonstreaming_manual_three_request_flow() { assert_private_request_sequence(&requests.lock().await); } -#[tokio::test] -async fn deferred_mcp_load_uses_existing_discovery_lifecycle_and_dispatch_once() { - let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; - let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![ - json!({ - "id": "upstream_search", - "object": "response", - "status": "completed", - "model": "test", - "created_at": 0, - "output": [{ - "type": "function_call", - "id": "fc_search_weather", - "call_id": "call_search_weather", - "name": "tool_search", - "arguments": "{\"query\":\"weather\"}", - "status": "completed" - }] - }), - json!({ - "id": "upstream_mcp_call", - "object": "response", - "status": "completed", - "model": "test", - "created_at": 0, - "output": [{ - "type": "function_call", - "id": "fc_forecast", - "call_id": "call_forecast", - "name": "mcp__weather__forecast", - "arguments": "{\"city\":\"Paris\"}", - "status": "completed" - }] - }), - json!({ - "id": "upstream_final", - "object": "response", - "status": "completed", - "model": "test", - "created_at": 0, - "output": [{ - "type": "message", - "id": "msg_final", - "role": "assistant", - "status": "completed", - "content": [{"type": "output_text", "text": "MCP_WEATHER_OK", "annotations": []}] - }] - }), - ]) - .await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let deferred_mcp = deferred_weather_mcp(&mcp_url); - let user = json!({"type": "message", "role": "user", "content": "find weather"}); - let tools = json!([search_declaration(), deferred_mcp.clone()]); - - let search_response = run(request(&json!([user.clone()]), &tools), Arc::clone(&context)).await; - assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 0); - assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 0); - assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); - let public_search_call = serde_json::to_value(&search_response.output[0]).expect("search call serializes"); - assert_eq!(public_search_call["type"], "tool_search_call"); - assert_eq!(public_search_call["call_id"], "call_search_weather"); - - let payload = request( - &json!([ - user, - public_search_call, - { - "type": "tool_search_output", - "call_id": "call_search_weather", - "tools": [deferred_mcp.clone()] - } - ]), - &tools, - ); - - let response = run(payload, context).await; - - assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 1); - assert!(matches!(&response.output[0], OutputItem::McpListTools(_))); - let OutputItem::McpCall(call) = &response.output[1] else { - panic!("loaded MCP call must use the normal public MCP lifecycle") - }; - assert_eq!(call.server_label, "weather"); - assert_eq!(call.name, "forecast"); - assert_eq!(call.output.as_deref(), Some("sunny")); - assert!(matches!(&response.output[2], OutputItem::Message(_))); - - assert_mcp_private_request_sequence(&requests.lock().await, &mcp_url); -} - -#[tokio::test] -async fn deferred_and_dynamic_mcp_discovery_rejects_matching_history_call_before_load() { - for declared_before_search in [true, false] { - let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; - let (llm_url, requests, _llm_server) = spawn_sequenced_llm(Vec::new()).await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let deferred_mcp = deferred_weather_mcp(&mcp_url); - let tools = if declared_before_search { - json!([search_declaration(), deferred_mcp.clone()]) - } else { - json!([search_declaration()]) - }; - let payload = request( - &json!([ - { - "type": "function_call", "id": "fc_early", "call_id": "call_early", - "name": "mcp__weather__forecast", "arguments": "{}", "status": "completed" - }, - {"type": "function_call_output", "call_id": "call_early", "output": "not executed"}, - { - "type": "tool_search_call", "id": "tsc_load", "call_id": "call_load", - "arguments": {"query": "weather"} - }, - {"type": "tool_search_output", "call_id": "call_load", "tools": [deferred_mcp]} - ]), - &tools, - ); - - let Err(error) = Box::pin(ExecuteRequest::new(payload, context).run()).await else { - panic!("an MCP function cannot be called before its server is loaded") - }; - - assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); - assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); - assert!(requests.lock().await.is_empty(), "inference must not run"); - } -} - -#[tokio::test] -async fn immediate_mcp_stays_available_before_an_identical_search_result() { - let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; - let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![json!({ - "id": "upstream_final", "object": "response", "status": "completed", "model": "test", - "created_at": 0, "output": [{ - "type": "message", "id": "msg_final", "role": "assistant", "status": "completed", - "content": [{"type": "output_text", "text": "IMMEDIATE_MCP_OK", "annotations": []}] - }] - })]) - .await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let mut immediate_mcp = deferred_weather_mcp(&mcp_url); - immediate_mcp - .as_object_mut() - .expect("MCP declaration") - .remove("defer_loading"); - let payload = request( - &json!([ - { - "type": "function_call", "id": "fc_early", "call_id": "call_early", - "name": "mcp__weather__forecast", "arguments": "{}", "status": "completed" - }, - {"type": "function_call_output", "call_id": "call_early", "output": "already handled"}, - { - "type": "tool_search_call", "id": "tsc_identical", "call_id": "call_identical", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", "call_id": "call_identical", - "tools": [immediate_mcp.clone()] - } - ]), - &json!([search_declaration(), immediate_mcp]), - ); - - let response = run(payload, context).await; - - assert!(matches!(&response.output[1], OutputItem::Message(_))); - assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); - assert_eq!(requests.lock().await.len(), 1, "request must reach inference"); -} - -#[tokio::test] -async fn loaded_deferred_mcp_failure_uses_sanitized_public_list_tools_item() { - let (llm_url, requests, _llm_server) = spawn_sequenced_llm(vec![json!({ - "id": "upstream_after_mcp_failure", - "object": "response", - "status": "completed", - "model": "test", - "created_at": 0, - "output": [{ - "type": "message", - "id": "msg_final", - "role": "assistant", - "status": "completed", - "content": [{"type": "output_text", "text": "MCP_FAILURE_HANDLED", "annotations": []}] - }] - })]) - .await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let deferred_mcp = json!({ - "type": "mcp", - "server_label": "private_weather", - "server_description": "Private weather server", - "server_url": "http://url-user:url-password@127.0.0.1:1/mcp?token=query-secret", - "headers": {"X-Private-Token": "header-secret"}, - "authorization": "authorization-secret", - "require_approval": "never", - "defer_loading": true - }); - let payload = request( - &json!([ - { - "type": "tool_search_call", - "id": "tsc_private_weather", - "call_id": "call_search_private_weather", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_private_weather", - "tools": [deferred_mcp.clone()] - } - ]), - &json!([search_declaration(), deferred_mcp]), - ); - - let response = run(payload, context).await; - - let OutputItem::McpListTools(list_tools) = &response.output[0] else { - panic!("loaded MCP configuration failure must retain public list-tools semantics") - }; - assert_eq!(list_tools.server_label, "private_weather"); - assert!(list_tools.tools.is_empty()); - assert_eq!( - list_tools.error.as_deref(), - Some("MCP server 'private_weather' failed to connect or list tools") - ); - assert!(matches!(&response.output[1], OutputItem::Message(_))); - - let public_response = serde_json::to_string(&response).expect("response serializes"); - let upstream_requests = requests.lock().await; - assert_eq!(upstream_requests.len(), 1); - let upstream_request = upstream_requests[0].to_string(); - for secret in [ - "url-user", - "url-password", - "query-secret", - "header-secret", - "authorization-secret", - ] { - assert!(!public_response.contains(secret), "public response leaked {secret}"); - assert!(!upstream_request.contains(secret), "upstream request leaked {secret}"); - } -} - -#[tokio::test] -async fn loaded_mcp_cannot_collide_with_a_still_withheld_function() { - let (mcp_url, mcp_counts, _mcp_server) = spawn_counting_mcp().await; - let (llm_url, requests, _llm_server) = spawn_sequenced_llm(Vec::new()).await; - let context = Arc::new(ExecutionContext::new( - ConversationHandler::new(ConversationStore::disabled()), - ResponseHandler::new(ResponseStore::disabled()), - Arc::new(reqwest::Client::new()), - llm_url, - )); - let deferred_mcp = deferred_weather_mcp(&mcp_url); - let payload = request( - &json!([ - { - "type": "tool_search_call", - "id": "tsc_collision", - "call_id": "call_search_collision", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_collision", - "tools": [deferred_mcp.clone()] - } - ]), - &json!([ - search_declaration(), - { - "type": "function", - "name": "mcp__weather__forecast", - "parameters": {"type": "object"}, - "defer_loading": true - }, - deferred_mcp - ]), - ); - - let Err(error) = Box::pin(ExecuteRequest::new(payload, context).run()).await else { - panic!("loaded MCP discovered name must not overwrite a declared function") - }; - - assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST); - assert_eq!(mcp_counts.initialize.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.list_tools.load(Ordering::SeqCst), 1); - assert_eq!(mcp_counts.call_tool.load(Ordering::SeqCst), 0); - assert!(requests.lock().await.is_empty(), "collision must fail before inference"); -} - #[tokio::test] async fn function_only_nonstreaming_malformed_search_is_atomic_before_gateway_side_effects() { for (case, malformed_search) in [ @@ -1476,17 +991,30 @@ async fn namespace_nonstreaming_manual_flow_reuses_flattening_and_restoration() "status": "completed", "tools": [loaded_weather_namespace_subset()] }); let second_input = json!([user.clone(), public_search_call, public_search_output]); - let second = run( - request(&second_input, &json!([namespace.clone()])), - Arc::clone(&context), - ) - .await; + let public_choice = json!({"type": "function", "namespace": namespace_name, "name": member_name}); + let mut second_request = request(&second_input, &json!([namespace.clone()])); + second_request.tool_choice = Some(serde_json::from_value(public_choice.clone()).expect("public namespace choice")); + let second = run(second_request, Arc::clone(&context)).await; let OutputItem::FunctionCall(call) = &second.output[0] else { panic!("loaded namespace member remains a client function call") }; assert_eq!(call.namespace.as_deref(), Some(namespace_name)); assert_eq!(call.name, member_name); assert_ne!(call.name, flat_name, "private flat name must not leak publicly"); + let mut available_namespace = namespace.clone(); + available_namespace["tools"][1] + .as_object_mut() + .expect("loaded namespace member") + .remove("defer_loading"); + assert_eq!( + serde_json::to_value(second.tools.as_ref().expect("response tools")).expect("response tools serialize"), + json!([available_namespace]) + ); + assert_eq!( + serde_json::to_value(second.tool_choice.as_ref().expect("response tool choice")) + .expect("response tool choice serializes"), + public_choice + ); let mut third_items = second_input.as_array().expect("history").clone(); third_items.push(serde_json::to_value(&second.output[0]).expect("namespace call serializes")); @@ -1498,6 +1026,8 @@ async fn namespace_nonstreaming_manual_flow_reuses_flattening_and_restoration() let requests = requests.lock().await; assert_eq!(requests.len(), 3); + assert_eq!(requests[1]["tool_choice"]["name"], flat_name); + assert!(requests[1]["tool_choice"].get("namespace").is_none()); assert_eq!(requests[1]["tools"].as_array().map(Vec::len), Some(2)); assert!( requests[1]["tools"] @@ -1741,7 +1271,7 @@ fn provider_parity_matrix_is_exact_and_gateway_has_no_private_search_leaks() { return None; } let cassette = support::load_cassette(path.to_str().expect("cassette path")); - (cassette.turns.len() == 3).then(|| { + (cassette.turns.len() == 4).then(|| { path.file_name() .and_then(|filename| filename.to_str()) .expect("cassette filename") diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index eef0db6b..25d1cc1d 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -14,7 +14,7 @@ use tracing::{debug, warn}; use agentic_core::ResponseUsage; use agentic_core::executor::{ - BoxStream, ExecuteRequest, ExecutorError, RequestContext, persist_turn, rehydrate_for_execution, + BoxStream, ExecuteRequest, ExecutorError, RequestContext, persist_turn, rehydrate_conversation, }; use agentic_core::types::request_response::RequestPayload; use agentic_core::utils::common::utcnow_str; @@ -257,7 +257,7 @@ async fn complete_without_inference( state: &AppState, payload: RequestPayload, ) -> Result<(), WsError> { - let ctx = rehydrate_for_execution(payload, &state.exec_ctx).await?; + let ctx = rehydrate_conversation(payload, &state.exec_ctx).await?; let created_at = utcnow_str(); let created_event = empty_response_event(&ctx, created_at, "response.created", "in_progress", 0, None); let completed_event = empty_response_event( diff --git a/crates/agentic-server/tests/responses_test.rs b/crates/agentic-server/tests/responses_test.rs index 3ab5f336..fe6ea3d3 100644 --- a/crates/agentic-server/tests/responses_test.rs +++ b/crates/agentic-server/tests/responses_test.rs @@ -13,7 +13,6 @@ use std::future::Future; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::task::{Context, Poll}; use tokio::net::TcpListener; use tokio::sync::{Mutex, oneshot}; @@ -403,25 +402,6 @@ async fn spawn_mock_vllm_json_capture_body( (format!("http://{addr}"), requests, handle) } -async fn spawn_counting_mcp_server() -> (String, Arc, tokio::task::JoinHandle<()>) { - let requests = Arc::new(AtomicUsize::new(0)); - let route_requests = Arc::clone(&requests); - let app = Router::new().route( - "/mcp", - post(move || { - let route_requests = Arc::clone(&route_requests); - async move { - route_requests.fetch_add(1, Ordering::SeqCst); - axum::Json(serde_json::json!({})) - } - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - (format!("http://{addr}/mcp"), requests, handle) -} - /// Spawn a mock vLLM that returns an SSE stream. async fn spawn_mock_vllm_sse() -> (String, tokio::task::JoinHandle<()>) { let app = Router::new().route( @@ -941,55 +921,6 @@ async fn test_blocking_tool_search_rejects_invalid_upstream_arguments_as_bad_gat assert_eq!(body["error"]["type"], "tool_error"); } -#[tokio::test] -async fn test_deferred_mcp_stays_withheld_before_valid_load() { - let (llm_url, upstream_requests, _llm) = spawn_mock_vllm_json_capture().await; - let (mcp_url, mcp_requests, _mcp) = spawn_counting_mcp_server().await; - let (gateway_url, _gateway) = spawn_gateway(test_state(&test_config(&llm_url))).await; - - let response = reqwest::Client::new() - .post(format!("{gateway_url}/v1/responses")) - .json(&serde_json::json!({ - "model": "test", - "input": "find a tool", - "tools": [ - { - "type": "tool_search", - "execution": "client", - "description": "Search the client catalog", - "parameters": {"type": "object"} - }, - { - "type": "mcp", - "server_label": "deferred_server", - "server_url": mcp_url, - "server_description": "Deferred private tools", - "defer_loading": true - } - ], - "store": false, - "stream": false - })) - .send() - .await - .expect("gateway response"); - - assert_eq!(response.status(), StatusCode::OK); - let upstream_requests = upstream_requests.lock().await; - assert_eq!(upstream_requests.len(), 1, "the synthetic search may reach upstream"); - assert_eq!(upstream_requests[0]["tools"].as_array().map(Vec::len), Some(1)); - assert_eq!(upstream_requests[0]["tools"][0]["name"], "tool_search"); - assert!( - !upstream_requests[0].to_string().contains(&mcp_url), - "deferred MCP endpoint must not enter the private upstream request" - ); - assert_eq!( - mcp_requests.load(Ordering::SeqCst), - 0, - "deferred MCP must not be connected or listed" - ); -} - #[tokio::test] async fn test_store_false_with_web_search_reaches_executor() { // Arrange diff --git a/crates/agentic-server/tests/responses_websocket_test.rs b/crates/agentic-server/tests/responses_websocket_test.rs index 545a5b04..3ac10770 100644 --- a/crates/agentic-server/tests/responses_websocket_test.rs +++ b/crates/agentic-server/tests/responses_websocket_test.rs @@ -884,9 +884,6 @@ async fn test_websocket_generate_false_prewarm_redacts_mcp_runtime_credentials() let lookup_ctx = RequestContext { original_request: request.clone(), enriched_request: request, - tool_search_state: None, - tool_search_private_request: None, - tool_search_loaded_tools: None, new_input_items: vec![], response_id: "resp_lookup".to_owned(), conversation_id: None, From 2baaf2df1f9b7decd6ff7abade183f490462765f Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Thu, 27 Aug 2026 13:49:02 +0000 Subject: [PATCH 06/11] Enhance validation Signed-off-by: haoshan98 --- .../src/executor/accumulator.rs | 322 +++++++++++++++++- .../src/types/io/output.rs | 57 +++- 2 files changed, 371 insertions(+), 8 deletions(-) diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 011216a2..4f5d6040 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -18,9 +18,9 @@ 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::tool::ToolType; +use crate::tool::{ToolType, tool_search::TOOL_SEARCH_NAME}; use crate::types::event::{MessageStatus, ResponseStatus}; -use crate::types::io::output::McpListTools; +use crate::types::io::output::{BlockingFunctionToolCall, McpListTools}; use crate::types::io::{ ApplyDone, CompactionItem, CustomToolCall, FunctionToolCall, OutputItem, OutputMessage, OutputTextContent, ReasoningOutput, ReasoningTextContent, ResponseUsage, ToolSearchCall, @@ -144,6 +144,29 @@ impl AccumulatedFunctionCall<'_> { } } +fn parse_blocking_output_item( + item: serde_json::Value, + invalid_tool_search_function: &mut bool, +) -> ExecutorResult> { + match item.get("type").and_then(serde_json::Value::as_str) { + Some("tool_search_call") => ToolSearchCall::from_blocking_output(item) + .map(OutputItem::ToolSearchCall) + .map(Some) + .map_err(ExecutorError::Tool), + Some("function_call") => { + if item.get("name").and_then(serde_json::Value::as_str) == Some(TOOL_SEARCH_NAME) + && BlockingFunctionToolCall::try_from(&item) + .and_then(FunctionToolCall::try_from) + .is_err() + { + *invalid_tool_search_function = true; + } + Ok(deserialize_from_value_opt::(item)) + } + _ => Ok(deserialize_from_value_opt::(item)), + } +} + /// Accumulates LLM response chunks from streaming or non-streaming sources. #[derive(Debug)] pub struct ResponseAccumulator { @@ -160,6 +183,10 @@ pub struct ResponseAccumulator { completed: Vec<(u32, OutputItem)>, /// Request-scoped model-visible tool classification. tool_types: HashMap, + /// Whether a blocking `function_call` named `tool_search` had an invalid + /// strict shape. It remains an ordinary compatibility call unless the + /// registry classifies that reserved name as synthetic tool search. + invalid_blocking_tool_search_function: bool, processing_error: Option, } @@ -178,6 +205,7 @@ impl ResponseAccumulator { in_flight: IndexMap::new(), completed: Vec::new(), tool_types: HashMap::new(), + invalid_blocking_tool_search_function: false, processing_error: None, } } @@ -187,6 +215,10 @@ impl ResponseAccumulator { tool_types: HashMap, withheld_function_names: &HashSet, ) -> ExecutorResult { + if self.invalid_blocking_tool_search_function && tool_types.get(TOOL_SEARCH_NAME) == Some(&ToolType::ToolSearch) + { + return Err(crate::tool::tool_search::invalid_upstream_search_call().into()); + } let discard_incomplete_tool_search = matches!(self.status, ResponseStatus::Error | ResponseStatus::Incomplete); let output = std::mem::take(&mut self.output); self.output = output @@ -221,7 +253,8 @@ impl ResponseAccumulator { /// Parses a non-streaming JSON response body. /// /// # Errors - /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. + /// Returns an error if the response JSON is invalid, required response + /// fields are missing, or a known tool-search output item is malformed. pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { let mut json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; let response_id = json["id"] @@ -229,13 +262,19 @@ impl ResponseAccumulator { .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))? .to_string(); + let mut invalid_blocking_tool_search_function = false; let output = deserialize_from_value_opt::>(json["output"].take()) .map(|items| { - let mut output = Vec::with_capacity(items.len()); - output.extend(items.into_iter().filter_map(deserialize_from_value_opt::)); - output + items + .into_iter() + .map(|item| parse_blocking_output_item(item, &mut invalid_blocking_tool_search_function)) + .collect::>>() }) - .unwrap_or_default(); + .transpose()? + .unwrap_or_default() + .into_iter() + .flatten() + .collect(); let status = json["status"] .as_str() @@ -256,6 +295,7 @@ impl ResponseAccumulator { in_flight: IndexMap::new(), completed: Vec::new(), tool_types: HashMap::new(), + invalid_blocking_tool_search_function, processing_error: None, }) } @@ -1456,6 +1496,274 @@ mod tests { assert!(matches!(acc.output[1], OutputItem::Message(_))); } + fn assert_invalid_blocking_tool_search_item(case: &str, item: &serde_json::Value) { + let body = serde_json::json!({ + "id": "resp_invalid", + "status": "completed", + "output": [item] + }); + let result = ResponseAccumulator::from_json(&body.to_string(), None).and_then(|accumulator| { + accumulator.with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + }); + let error = result.expect_err("malformed known item must not be dropped or accepted"); + assert!(error.is_invalid_upstream_tool_search(), "{case}: {error:?}"); + assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY, "{case}"); + } + + #[test] + fn blocking_rejects_malformed_native_tool_search_calls() { + let valid = serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_search", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }); + let invalid_fields = [ + ("id", None), + ("id", Some(serde_json::json!(" "))), + ("call_id", None), + ("call_id", Some(serde_json::Value::Null)), + ("call_id", Some(serde_json::json!(" "))), + ("call_id", Some(serde_json::json!(7))), + ("arguments", None), + ("arguments", Some(serde_json::Value::Null)), + ("arguments", Some(serde_json::json!("not an object"))), + ("arguments", Some(serde_json::json!([]))), + ("status", None), + ("status", Some(serde_json::Value::Null)), + ("status", Some(serde_json::json!(7))), + ("status", Some(serde_json::json!("in_progress"))), + ("execution", None), + ("execution", Some(serde_json::json!("server"))), + ("namespace", Some(serde_json::json!("tools"))), + ]; + + for (field, replacement) in invalid_fields { + let mut item = valid.clone(); + let case = format!("native {field}: {replacement:?}"); + match replacement { + Some(value) => item[field] = value, + None => { + item.as_object_mut().unwrap().remove(field); + } + } + assert_invalid_blocking_tool_search_item(&case, &item); + } + } + + #[test] + fn blocking_rejects_malformed_synthetic_tool_search_calls_before_serde_defaults() { + let valid = serde_json::json!({ + "type": "function_call", + "id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "namespace": null, + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }); + let invalid_fields = [ + ("id", None), + ("id", Some(serde_json::json!(" "))), + ("call_id", None), + ("call_id", Some(serde_json::Value::Null)), + ("call_id", Some(serde_json::json!(" "))), + ("call_id", Some(serde_json::json!(7))), + ("arguments", None), + ("arguments", Some(serde_json::Value::Null)), + ("arguments", Some(serde_json::json!({}))), + ("arguments", Some(serde_json::json!("{"))), + ("arguments", Some(serde_json::json!("[]"))), + ("status", None), + ("status", Some(serde_json::Value::Null)), + ("status", Some(serde_json::json!(7))), + ("status", Some(serde_json::json!("in_progress"))), + ("namespace", Some(serde_json::json!("tools"))), + ]; + + for (field, replacement) in invalid_fields { + let mut item = valid.clone(); + let case = format!("synthetic {field}: {replacement:?}"); + match replacement { + Some(value) => item[field] = value, + None => { + item.as_object_mut().unwrap().remove(field); + } + } + assert_invalid_blocking_tool_search_item(&case, &item); + } + } + + #[test] + fn blocking_ordinary_function_named_tool_search_keeps_compatibility_defaults_when_inactive() { + let body = serde_json::json!({ + "id": "resp_ordinary", + "status": "completed", + "output": [{ + "type": "function_call", + "call_id": "call_ordinary", + "name": "tool_search", + "arguments": "{}", + "status": null + }] + }); + + let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) + .unwrap() + .with_tool_types(HashMap::new(), &HashSet::new()) + .unwrap(); + let [OutputItem::FunctionCall(call)] = accumulator.output.as_slice() else { + panic!("inactive ordinary function must retain generic function-call parsing") + }; + assert!(call.id.starts_with("fc_")); + assert_eq!(call.call_id, "call_ordinary"); + assert_eq!(call.name, "tool_search"); + assert_eq!(call.status, MessageStatus::Completed); + } + + #[test] + fn blocking_preserves_valid_native_and_synthetic_tool_search_calls() { + let native_body = serde_json::json!({ + "id": "resp_native", + "status": "completed", + "output": [{ + "type": "tool_search_call", + "id": "tsc_native", + "call_id": "call_native", + "execution": "client", + "namespace": null, + "arguments": {"query": "weather"}, + "status": "completed" + }] + }); + let native = ResponseAccumulator::from_json(&native_body.to_string(), None).unwrap(); + assert!(matches!(native.output.as_slice(), [OutputItem::ToolSearchCall(call)] if call.id == "tsc_native")); + + let synthetic_body = serde_json::json!({ + "id": "resp_synthetic", + "status": "completed", + "output": [{ + "type": "function_call", + "id": "fc_synthetic", + "call_id": "call_synthetic", + "name": "tool_search", + "namespace": null, + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }] + }); + let synthetic = ResponseAccumulator::from_json(&synthetic_body.to_string(), None) + .unwrap() + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .unwrap(); + assert!(matches!(synthetic.output.as_slice(), [OutputItem::ToolSearchCall(call)] + if call.id == "tsc_synthetic" && call.call_id == "call_synthetic")); + } + + #[test] + fn blocking_incomplete_response_discards_unfinished_tool_search_calls() { + let items = [ + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_native", + "call_id": "call_native", + "execution": "client", + "arguments": {}, + "status": "in_progress" + }), + serde_json::json!({ + "type": "tool_search_call", + "id": "tsc_native", + "call_id": "call_native", + "execution": "client", + "arguments": {}, + "status": "incomplete" + }), + serde_json::json!({ + "type": "function_call", + "id": "fc_synthetic", + "call_id": "call_synthetic", + "name": "tool_search", + "arguments": "{}", + "status": "in_progress" + }), + ]; + + for item in items { + let body = serde_json::json!({ + "id": "resp_incomplete", + "status": "incomplete", + "output": [item] + }); + let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) + .unwrap() + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .unwrap(); + assert!(accumulator.output.is_empty()); + } + + for response_status in ["incomplete", "error", "failed"] { + let body = serde_json::json!({ + "id": "resp_terminal", + "status": response_status, + "output": [{ + "type": "function_call", + "id": "fc_partial", + "call_id": "call_partial", + "name": "tool_search", + "arguments": "{\"query\":", + "status": "in_progress" + }] + }); + let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) + .unwrap() + .with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + .unwrap(); + assert!(accumulator.output.is_empty(), "{response_status}"); + } + } + + #[test] + fn blocking_completed_response_rejects_in_progress_synthetic_call_with_partial_arguments() { + let body = serde_json::json!({ + "id": "resp_completed", + "status": "completed", + "output": [{ + "type": "function_call", + "id": "fc_partial", + "call_id": "call_partial", + "name": "tool_search", + "arguments": "{\"query\":", + "status": "in_progress" + }] + }); + let error = ResponseAccumulator::from_json(&body.to_string(), None) + .and_then(|accumulator| { + accumulator.with_tool_types( + HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), + &HashSet::new(), + ) + }) + .expect_err("completed response must reject unfinished tool search"); + + assert!(error.is_invalid_upstream_tool_search()); + assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY); + } + #[test] fn test_blocking_preserves_all_documented_mcp_call_statuses() { let cases: [(Option<&str>, Option); 3] = [ diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index 81ac9e47..2b3cb0e4 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -6,7 +6,7 @@ use crate::executor::error::ExecutorError; use crate::tool::{ToolError, ToolRegistry, tool_search}; use crate::types::event::MessageStatus; use crate::types::tools::{ToolSearchExecution, ToolSearchStatus}; -use crate::utils::common::deserialize_from_value_opt; +use crate::utils::common::{deserialize_from_str, deserialize_from_value, deserialize_from_value_opt}; use crate::utils::uuid7_str; use super::input::{ @@ -104,6 +104,30 @@ pub struct FunctionToolCall { pub status: MessageStatus, } +/// Strict non-streaming wire shape used before tool classification. +/// +/// [`FunctionToolCall`] intentionally supplies compatibility defaults for +/// ordinary functions. This private shape lets the executor remember whether +/// a call would be valid if its name is later classified as tool search. +#[derive(Debug, Deserialize)] +pub(crate) struct BlockingFunctionToolCall { + id: String, + call_id: String, + name: String, + #[serde(default)] + namespace: Option, + arguments: String, + status: MessageStatus, +} + +impl TryFrom<&Value> for BlockingFunctionToolCall { + type Error = ToolError; + + fn try_from(value: &Value) -> Result { + deserialize_from_value(value.clone()).map_err(|_| tool_search::invalid_upstream_search_call()) + } +} + /// A newly emitted public client tool-search call. /// /// Unlike replay input, execution and status have no serde defaults: response @@ -137,6 +161,13 @@ impl TryFrom<&FunctionToolCall> for ToolSearchCall { } impl ToolSearchCall { + pub(crate) fn from_blocking_output(value: Value) -> Result { + if value.get("namespace").is_some_and(|namespace| !namespace.is_null()) { + return Err(tool_search::invalid_upstream_search_call()); + } + deserialize_from_value(value).map_err(|_| tool_search::invalid_upstream_search_call()) + } + pub(crate) fn started_from_function(call: &FunctionToolCall) -> Result { if call.id.trim().is_empty() || call.call_id.trim().is_empty() @@ -155,6 +186,30 @@ impl ToolSearchCall { } } +impl TryFrom for FunctionToolCall { + type Error = ToolError; + + fn try_from(call: BlockingFunctionToolCall) -> Result { + if call.namespace.is_some() { + return Err(tool_search::invalid_upstream_search_call()); + } + let call = Self { + id: call.id, + call_id: call.call_id, + name: call.name, + namespace: None, + arguments: call.arguments, + status: call.status, + }; + ToolSearchCall::started_from_function(&call)?; + if call.status == MessageStatus::Completed { + deserialize_from_str::>(&call.arguments) + .map_err(|_| tool_search::invalid_upstream_search_call())?; + } + Ok(call) + } +} + impl TryFrom<&EventPayload> for ToolSearchCall { type Error = ToolError; From 2e31d1f729d0749675eda1f1457674d49f72fd50 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Thu, 27 Aug 2026 14:09:13 +0000 Subject: [PATCH 07/11] Keep compaction trigger Signed-off-by: haoshan98 --- crates/agentic-server-core/src/executor/engine.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index adcc010f..b012c3d2 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -103,7 +103,7 @@ async fn run_until_gateway_tools_complete( stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { - if ctx.request().enriched_request.input.has_compaction_trigger() { + if ctx.request().original_request.input.has_compaction_trigger() { let (payload, ctx) = run_compaction_trigger(ctx, exec_ctx, auth).await?; if let Some((stream_accumulator, stream_sender)) = stream.as_mut() { emit_response_start_events(&payload, stream_accumulator, stream_sender)?; @@ -895,6 +895,11 @@ mod tests { panic!("non-streaming trigger request must return a payload"); }; + let [OutputItem::Compaction(compaction)] = response.output.as_slice() else { + panic!("tool-search compaction trigger must return one compaction item"); + }; + assert_eq!(compaction.encrypted_content, "durable summary"); + let upstream = captured.lock().await.take().expect("summary inference ran"); let upstream_input = upstream["input"].as_array().expect("summary input items"); assert!(upstream_input.iter().any(|item| { From a2fe3fa02b5b559ae6e6f3ed42fbb3c7dbb40546 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Fri, 28 Aug 2026 07:11:05 +0000 Subject: [PATCH 08/11] ToolRegistry-centered redesign Signed-off-by: haoshan98 --- .../src/events/normalize.rs | 3 - .../agentic-server-core/src/events/types.rs | 7 - .../src/executor/accumulator.rs | 698 ++------------- .../src/executor/compaction.rs | 27 +- .../src/executor/engine.rs | 193 ++-- .../src/executor/function_sse.rs | 651 +------------- .../src/executor/modes/conversation.rs | 9 +- .../src/executor/modes/response.rs | 9 +- .../src/executor/persist.rs | 44 +- .../src/executor/prepare.rs | 14 +- .../src/executor/rehydrate.rs | 33 +- .../src/executor/request.rs | 78 +- .../src/executor/upstream.rs | 100 +-- crates/agentic-server-core/src/tool/mod.rs | 1 - .../agentic-server-core/src/tool/registry.rs | 239 ++++- .../src/tool/tool_search.rs | 838 ++++++++++++++++-- .../agentic-server-core/src/types/io/input.rs | 31 - .../src/types/io/output.rs | 50 -- .../src/types/request_response.rs | 144 +-- .../tests/event_normalizer_test.rs | 26 +- .../src/handler/http/responses.rs | 6 +- .../src/handler/websocket/responses.rs | 2 +- 22 files changed, 1291 insertions(+), 1912 deletions(-) diff --git a/crates/agentic-server-core/src/events/normalize.rs b/crates/agentic-server-core/src/events/normalize.rs index d46818ed..c83da97d 100644 --- a/crates/agentic-server-core/src/events/normalize.rs +++ b/crates/agentic-server-core/src/events/normalize.rs @@ -108,9 +108,6 @@ fn extract_output_item_added(json: &Value) -> EventPayload { name: json_str_opt(item, "name"), namespace: json_str_opt(item, "namespace"), call_id: json_str_opt(item, "call_id"), - execution: item.get("execution").cloned().and_then(deserialize_from_value_opt), - status: json_str_opt(item, "status"), - arguments: item.get("arguments").and_then(Value::as_object).cloned(), } } diff --git a/crates/agentic-server-core/src/events/types.rs b/crates/agentic-server-core/src/events/types.rs index da938ab5..ae012736 100644 --- a/crates/agentic-server-core/src/events/types.rs +++ b/crates/agentic-server-core/src/events/types.rs @@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::types::io::ResponseUsage; -use crate::types::tools::ToolSearchExecution; /// The type of an output item received during streaming. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -13,7 +12,6 @@ pub enum SSEItemType { WebSearchCall, McpCall, McpListTools, - ToolSearchCall, Compaction, Message, } @@ -28,7 +26,6 @@ impl SSEItemType { Self::WebSearchCall => "web_search_call", Self::McpCall => "mcp_call", Self::McpListTools => "mcp_list_tools", - Self::ToolSearchCall => "tool_search_call", Self::Compaction => "compaction", Self::Message => "message", } @@ -44,7 +41,6 @@ impl From<&str> for SSEItemType { "web_search_call" => Self::WebSearchCall, "mcp_call" => Self::McpCall, "mcp_list_tools" => Self::McpListTools, - "tool_search_call" => Self::ToolSearchCall, "compaction" => Self::Compaction, _ => Self::Message, } @@ -256,9 +252,6 @@ pub enum EventPayload { name: Option, namespace: Option, call_id: Option, - execution: Option, - status: Option, - arguments: Option>, }, /// `response.output_item.done` diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 4f5d6040..166954d4 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -7,7 +7,6 @@ //! runs on a blocking thread while the async task continues reading from the //! network — keeping the tokio executor thread free between chunk arrivals. -use std::collections::{HashMap, HashSet}; use std::pin::Pin; use std::sync::mpsc; @@ -18,16 +17,14 @@ 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::tool::{ToolType, tool_search::TOOL_SEARCH_NAME}; use crate::types::event::{MessageStatus, ResponseStatus}; -use crate::types::io::output::{BlockingFunctionToolCall, McpListTools}; +use crate::types::io::output::McpListTools; use crate::types::io::{ ApplyDone, CompactionItem, CustomToolCall, FunctionToolCall, OutputItem, OutputMessage, OutputTextContent, - ReasoningOutput, ReasoningTextContent, ResponseUsage, ToolSearchCall, + ReasoningOutput, ReasoningTextContent, ResponseUsage, }; use crate::types::io::{McpCall, WebSearchCall}; use crate::types::request_response::{IncompleteDetails, ResponsePayload}; -use crate::types::tools::ToolSearchStatus; use crate::utils::common::{deserialize_from_str, deserialize_from_value_opt}; use crate::utils::uuid7_str; @@ -41,7 +38,6 @@ enum InFlight { WebSearchCall { item: Option }, McpCall { item: McpCall }, McpListTools { item: McpListTools }, - ToolSearchCall(ToolSearchCall), Compaction { item: CompactionItem }, } @@ -55,68 +51,45 @@ impl std::fmt::Debug for InFlight { Self::WebSearchCall { .. } => write!(f, "InFlight::WebSearchCall {{ .. }}"), Self::McpCall { .. } => write!(f, "InFlight::McpCall {{ .. }}"), Self::McpListTools { .. } => write!(f, "InFlight::McpListTools {{ .. }}"), - Self::ToolSearchCall(..) => write!(f, "InFlight::ToolSearchCall(..)"), Self::Compaction { .. } => write!(f, "InFlight::Compaction {{ .. }}"), } } } impl InFlight { - fn finalize( - self, - tool_types: &HashMap, - discard_incomplete_tool_search: bool, - ) -> ExecutorResult> { + fn finalize(self) -> Option { match self { Self::Reasoning { mut item, text } => { if !text.is_empty() { item.content.push(ReasoningTextContent::new(text)); } - Ok(Some(OutputItem::Reasoning(item))) + Some(OutputItem::Reasoning(item)) } Self::FunctionCall { mut item, arguments } => { - if tool_types.get(&item.name) == Some(&ToolType::ToolSearch) - && item.status != MessageStatus::Completed - && discard_incomplete_tool_search - { - return Ok(None); - } if !arguments.is_empty() && item.arguments.is_empty() { item.arguments = arguments; } item.status = MessageStatus::Completed; - if tool_types.get(&item.name) == Some(&ToolType::ToolSearch) { - ToolSearchCall::try_from(&item) - .map(OutputItem::ToolSearchCall) - .map(Some) - .map_err(ExecutorError::Tool) - } else { - Ok(Some(OutputItem::FunctionCall(item))) - } + Some(OutputItem::FunctionCall(item)) } Self::Message { mut item, text } => { if !text.is_empty() { item.content.push(OutputTextContent::new(text)); } item.status = MessageStatus::Completed; - Ok(Some(OutputItem::Message(item))) + Some(OutputItem::Message(item)) } Self::CustomToolCall { mut item, input } => { if item.input.is_empty() { item.input = input; } item.status = Some(MessageStatus::Completed); - Ok(Some(OutputItem::CustomToolCall(item))) - } - Self::WebSearchCall { item } => Ok(item.map(OutputItem::WebSearchCall)), - Self::McpCall { item } => Ok(Some(OutputItem::McpCall(item))), - Self::McpListTools { item } => Ok(Some(OutputItem::McpListTools(item))), - Self::ToolSearchCall(item) if item.status == ToolSearchStatus::Completed => { - Ok(Some(OutputItem::ToolSearchCall(item))) + Some(OutputItem::CustomToolCall(item)) } - Self::ToolSearchCall(_) if discard_incomplete_tool_search => Ok(None), - Self::ToolSearchCall(_) => Err(crate::tool::tool_search::invalid_upstream_search_call().into()), - Self::Compaction { item } => Ok(Some(OutputItem::Compaction(item))), + Self::WebSearchCall { item } => item.map(OutputItem::WebSearchCall), + Self::McpCall { item } => Some(OutputItem::McpCall(item)), + Self::McpListTools { item } => Some(OutputItem::McpListTools(item)), + Self::Compaction { item } => Some(OutputItem::Compaction(item)), } } } @@ -144,29 +117,6 @@ impl AccumulatedFunctionCall<'_> { } } -fn parse_blocking_output_item( - item: serde_json::Value, - invalid_tool_search_function: &mut bool, -) -> ExecutorResult> { - match item.get("type").and_then(serde_json::Value::as_str) { - Some("tool_search_call") => ToolSearchCall::from_blocking_output(item) - .map(OutputItem::ToolSearchCall) - .map(Some) - .map_err(ExecutorError::Tool), - Some("function_call") => { - if item.get("name").and_then(serde_json::Value::as_str) == Some(TOOL_SEARCH_NAME) - && BlockingFunctionToolCall::try_from(&item) - .and_then(FunctionToolCall::try_from) - .is_err() - { - *invalid_tool_search_function = true; - } - Ok(deserialize_from_value_opt::(item)) - } - _ => Ok(deserialize_from_value_opt::(item)), - } -} - /// Accumulates LLM response chunks from streaming or non-streaming sources. #[derive(Debug)] pub struct ResponseAccumulator { @@ -181,13 +131,6 @@ pub struct ResponseAccumulator { in_flight: IndexMap, /// Completed streaming items waiting to be emitted in `output_index` order. completed: Vec<(u32, OutputItem)>, - /// Request-scoped model-visible tool classification. - tool_types: HashMap, - /// Whether a blocking `function_call` named `tool_search` had an invalid - /// strict shape. It remains an ordinary compatibility call unless the - /// registry classifies that reserved name as synthetic tool search. - invalid_blocking_tool_search_function: bool, - processing_error: Option, } impl ResponseAccumulator { @@ -204,77 +147,28 @@ impl ResponseAccumulator { error: None, in_flight: IndexMap::new(), completed: Vec::new(), - tool_types: HashMap::new(), - invalid_blocking_tool_search_function: false, - processing_error: None, } } - pub(super) fn with_tool_types( - mut self, - tool_types: HashMap, - withheld_function_names: &HashSet, - ) -> ExecutorResult { - if self.invalid_blocking_tool_search_function && tool_types.get(TOOL_SEARCH_NAME) == Some(&ToolType::ToolSearch) - { - return Err(crate::tool::tool_search::invalid_upstream_search_call().into()); - } - let discard_incomplete_tool_search = matches!(self.status, ResponseStatus::Error | ResponseStatus::Incomplete); - let output = std::mem::take(&mut self.output); - self.output = output - .into_iter() - .map(|item| { - if matches!(&item, OutputItem::FunctionCall(call) if withheld_function_names.contains(&call.name)) { - return Err(crate::tool::tool_search::invalid_upstream_withheld_function_call().into()); - } - if discard_incomplete_tool_search - && matches!(&item, OutputItem::FunctionCall(call) - if tool_types.get(&call.name) == Some(&ToolType::ToolSearch) - && call.status != MessageStatus::Completed) - { - return Ok(None); - } - if discard_incomplete_tool_search - && matches!(&item, OutputItem::ToolSearchCall(call) - if call.status != ToolSearchStatus::Completed) - { - return Ok(None); - } - normalize_output_item(item, &tool_types).map(Some) - }) - .collect::>>()? - .into_iter() - .flatten() - .collect(); - self.tool_types = tool_types; - Ok(self) - } - /// Parses a non-streaming JSON response body. /// /// # Errors - /// Returns an error if the response JSON is invalid, required response - /// fields are missing, or a known tool-search output item is malformed. + /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { let mut json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; + let response_id = json["id"] .as_str() .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))? .to_string(); - let mut invalid_blocking_tool_search_function = false; let output = deserialize_from_value_opt::>(json["output"].take()) .map(|items| { - items - .into_iter() - .map(|item| parse_blocking_output_item(item, &mut invalid_blocking_tool_search_function)) - .collect::>>() + let mut out = Vec::with_capacity(items.len()); + out.extend(items.into_iter().filter_map(deserialize_from_value_opt::)); + out }) - .transpose()? - .unwrap_or_default() - .into_iter() - .flatten() - .collect(); + .unwrap_or_default(); let status = json["status"] .as_str() @@ -294,9 +188,6 @@ impl ResponseAccumulator { error, in_flight: IndexMap::new(), completed: Vec::new(), - tool_types: HashMap::new(), - invalid_blocking_tool_search_function, - processing_error: None, }) } @@ -338,20 +229,17 @@ impl ResponseAccumulator { // Properly async join — does not block the tokio executor thread. worker_handle .await - .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into()))? + .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into())) } /// Worker function that processes SSE lines from the channel (runs on blocking thread). - fn process_stream_chunks(rx: mpsc::Receiver, conversation_id: Option) -> ExecutorResult { + fn process_stream_chunks(rx: mpsc::Receiver, conversation_id: Option) -> Self { let mut acc = Self::new(uuid7_str("resp_"), conversation_id); for line in rx { let _ = acc.process_sse_line(&line); } acc.finish_stream(); - if let Some(error) = acc.take_processing_error() { - return Err(error); - } - Ok(acc) + acc } /// Processes pre-collected raw SSE lines synchronously. @@ -371,14 +259,11 @@ impl ResponseAccumulator { /// Finalizes all streaming items in upstream `output_index` order. pub(crate) fn finalize_all(&mut self) { - let discard_incomplete_tool_search = matches!(self.status, ResponseStatus::Error | ResponseStatus::Incomplete); - for (_, entry) in self.in_flight.drain(..) { - match entry.item.finalize(&self.tool_types, discard_incomplete_tool_search) { - Ok(Some(item)) => self.completed.push((entry.output_index, item)), - Err(error) if self.processing_error.is_none() => self.processing_error = Some(error), - Ok(None) | Err(_) => {} - } - } + self.completed.extend( + self.in_flight + .drain(..) + .filter_map(|(_, entry)| entry.item.finalize().map(|item| (entry.output_index, item))), + ); self.completed.sort_by_key(|(output_index, _)| *output_index); self.output .extend(self.completed.drain(..).map(|(_, output_item)| output_item)); @@ -386,7 +271,8 @@ impl ResponseAccumulator { pub(crate) fn process_sse_line(&mut self, line: &str) -> Option { let frame = normalize_sse_line(line)?; - self.process_normalized_frame(&frame); + self.capture_terminal_details_if_needed(&frame); + self.process_event(&frame); Some(frame) } @@ -395,25 +281,12 @@ impl ResponseAccumulator { line: &str, translator: &mut FunctionSseTranslator, ) -> ExecutorResult> { - let Some(frame) = normalize_sse_line(line) else { + 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.validate_before_accumulation(&frame, call)?; - self.process_normalized_frame(&frame); - if let Some(error) = self.take_processing_error() { - return Err(error); - } - let call = call_key.and_then(|(item_id, output_index)| self.accumulated_function_call(item_id, output_index)); - let tool_search_call = - call_key.and_then(|(item_id, output_index)| self.accumulated_tool_search_call(item_id, output_index)); - translator.translate(frame, call, tool_search_call).map(Some) - } - - fn process_normalized_frame(&mut self, frame: &EventFrame) { - self.capture_terminal_details_if_needed(frame); - self.process_event(frame); + translator.translate(frame, call).map(Some) } fn accumulated_function_call(&self, item_id: &str, output_index: u32) -> Option> { @@ -426,14 +299,14 @@ impl ResponseAccumulator { entry.output_index == output_index && matches!(entry.item, InFlight::FunctionCall { .. }) }) }); - if let Some(entry) = entry { - let (item, arguments) = match &entry.item { - InFlight::FunctionCall { item, arguments } => (item, arguments.as_str()), - _ => return None, - }; + if let Some(InFlightEntry { + output_index, + item: InFlight::FunctionCall { item, arguments }, + }) = entry + { return Some(AccumulatedFunctionCall { item, - output_index: entry.output_index, + output_index: *output_index, arguments, }); } @@ -450,13 +323,6 @@ impl ResponseAccumulator { }) } - fn accumulated_tool_search_call(&self, item_id: &str, output_index: u32) -> Option<&ToolSearchCall> { - self.in_flight.get(item_id).and_then(|entry| match &entry.item { - InFlight::ToolSearchCall(item) if entry.output_index == output_index => Some(item), - _ => None, - }) - } - fn capture_terminal_details(&mut self, frame: &EventFrame) { let Some(response) = frame.wire.rest.get("response") else { return; @@ -499,11 +365,7 @@ impl ResponseAccumulator { self.start_output_item(payload); } (SSEEventType::OutputItemDone, payload @ EventPayload::OutputItemDone { .. }) => { - if let Err(error) = self.complete_call_item(payload) - && self.processing_error.is_none() - { - self.processing_error = Some(error); - } + self.complete_call_item(payload); } (SSEEventType::ReasoningTextDelta, EventPayload::ReasoningDelta { delta, item_id }) => { if let Some(InFlight::Reasoning { text, .. }) = @@ -628,15 +490,6 @@ impl ResponseAccumulator { SSEItemType::McpListTools => McpListTools::try_from(payload) .ok() .map(|item| InFlight::McpListTools { item }), - SSEItemType::ToolSearchCall => match ToolSearchCall::try_from(payload) { - Ok(item) => Some(InFlight::ToolSearchCall(item)), - Err(error) => { - if self.processing_error.is_none() { - self.processing_error = Some(error.into()); - } - None - } - }, }; if let Some(item) = item { let needs_internal_key = matches!(&item, InFlight::FunctionCall { .. }) @@ -661,12 +514,12 @@ impl ResponseAccumulator { } fn finish_response(&mut self, status: ResponseStatus, usage: Option) { - self.status = status; self.finalize_all(); + self.status = status; self.usage = usage; } - fn complete_call_item(&mut self, payload: &EventPayload) -> ExecutorResult<()> { + fn complete_call_item(&mut self, payload: &EventPayload) { let EventPayload::OutputItemDone { item_id, item_type, @@ -675,46 +528,17 @@ impl ResponseAccumulator { .. } = payload else { - return Ok(()); + 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)) { - let replacement = match (&mut entry.item, done_item) { - (InFlight::FunctionCall { item, arguments }, _) => { - let is_tool_search = self.tool_types.get(&item.name) == Some(&ToolType::ToolSearch) - || raw_item - .get("name") - .and_then(serde_json::Value::as_str) - .is_some_and(|name| self.tool_types.get(name) == Some(&ToolType::ToolSearch)); - item.apply_done(payload, arguments); - if is_tool_search { - let public = ToolSearchCall::try_from(&*item).map_err(ExecutorError::Tool)?; - Some(InFlight::ToolSearchCall(public)) - } else { - None - } - } - (InFlight::CustomToolCall { item, input }, _) => { - item.apply_done(payload, input); - None - } - (InFlight::McpCall { item }, _) => { - item.apply_done(payload, &mut String::new()); - None - } - (InFlight::McpListTools { item }, _) => { - item.apply_done(payload, &mut String::new()); - None - } - (InFlight::ToolSearchCall(item), _) => { - item.apply_done(payload, &mut String::new()); - None - } - (InFlight::Compaction { item }, _) => { - item.apply_done(payload, &mut String::new()); - None - } + 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::McpListTools { item }, _) => item.apply_done(payload, &mut String::new()), + (InFlight::Compaction { 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 @@ -723,45 +547,30 @@ impl ResponseAccumulator { .map_or_else(|| uuid7_str("ws_"), str::to_owned); } *item = Some(call); - None } - _ => None, - }; - if let Some(replacement) = replacement { - entry.item = replacement; + _ => {} } - return Ok(()); + return; } - self.complete_untracked_call_item(done_item, *output_index) - } - - fn complete_untracked_call_item(&mut self, done_item: Option, output_index: u32) -> ExecutorResult<()> { - let Some(mut output_item) = done_item - .map(|item| normalize_output_item(item, &self.tool_types)) - .transpose()? - else { - return Ok(()); - }; - if !matches!( - output_item, - OutputItem::FunctionCall(_) - | OutputItem::ToolSearchCall(_) - | OutputItem::CustomToolCall(_) - | OutputItem::WebSearchCall(_) - | OutputItem::McpCall(_) - | OutputItem::McpListTools(_) - | OutputItem::Compaction(_) - ) { - return Ok(()); - } - if let OutputItem::WebSearchCall(call) = &mut output_item - && call.id.is_empty() + if let Some( + mut output_item @ (OutputItem::FunctionCall(_) + | OutputItem::CustomToolCall(_) + | OutputItem::WebSearchCall(_) + | OutputItem::McpCall(_) + | OutputItem::McpListTools(_) + | OutputItem::Compaction(_)), + ) = done_item { - call.id = uuid7_str("ws_"); + let OutputItem::WebSearchCall(call) = &mut output_item else { + self.completed.push((*output_index, output_item)); + return; + }; + if call.id.is_empty() { + call.id = uuid7_str("ws_"); + } + self.completed.push((*output_index, output_item)); } - self.completed.push((output_index, output_item)); - Ok(()) } fn in_flight_call_key(&self, item_id: &str, item_type: SSEItemType, output_index: u32) -> Option { @@ -785,10 +594,6 @@ impl ResponseAccumulator { }); } - pub(super) fn take_processing_error(&mut self) -> Option { - self.processing_error.take() - } - /// Finalizes the accumulator into a `ResponsePayload`. /// /// The caller supplies fields that come from the original request, not from @@ -827,25 +632,10 @@ fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool | (InFlight::WebSearchCall { .. }, SSEItemType::WebSearchCall) | (InFlight::McpCall { .. }, SSEItemType::McpCall) | (InFlight::McpListTools { .. }, SSEItemType::McpListTools) - | (InFlight::ToolSearchCall(_), SSEItemType::ToolSearchCall) | (InFlight::Compaction { .. }, SSEItemType::Compaction) ) } -fn normalize_output_item(item: OutputItem, tool_types: &HashMap) -> ExecutorResult { - match item { - OutputItem::FunctionCall(call) if tool_types.get(&call.name) == Some(&ToolType::ToolSearch) => { - ToolSearchCall::try_from(&call) - .map(OutputItem::ToolSearchCall) - .map_err(ExecutorError::Tool) - } - OutputItem::ToolSearchCall(call) if call.status != ToolSearchStatus::Completed => { - Err(crate::tool::tool_search::invalid_upstream_search_call().into()) - } - item => Ok(item), - } -} - fn function_event_key(payload: &EventPayload) -> Option<(&str, u32)> { match payload { EventPayload::OutputItemAdded { @@ -1004,9 +794,6 @@ mod tests { name: None, namespace: None, call_id: None, - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -1496,274 +1283,6 @@ mod tests { assert!(matches!(acc.output[1], OutputItem::Message(_))); } - fn assert_invalid_blocking_tool_search_item(case: &str, item: &serde_json::Value) { - let body = serde_json::json!({ - "id": "resp_invalid", - "status": "completed", - "output": [item] - }); - let result = ResponseAccumulator::from_json(&body.to_string(), None).and_then(|accumulator| { - accumulator.with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - }); - let error = result.expect_err("malformed known item must not be dropped or accepted"); - assert!(error.is_invalid_upstream_tool_search(), "{case}: {error:?}"); - assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY, "{case}"); - } - - #[test] - fn blocking_rejects_malformed_native_tool_search_calls() { - let valid = serde_json::json!({ - "type": "tool_search_call", - "id": "tsc_search", - "call_id": "call_search", - "execution": "client", - "arguments": {"query": "weather"}, - "status": "completed" - }); - let invalid_fields = [ - ("id", None), - ("id", Some(serde_json::json!(" "))), - ("call_id", None), - ("call_id", Some(serde_json::Value::Null)), - ("call_id", Some(serde_json::json!(" "))), - ("call_id", Some(serde_json::json!(7))), - ("arguments", None), - ("arguments", Some(serde_json::Value::Null)), - ("arguments", Some(serde_json::json!("not an object"))), - ("arguments", Some(serde_json::json!([]))), - ("status", None), - ("status", Some(serde_json::Value::Null)), - ("status", Some(serde_json::json!(7))), - ("status", Some(serde_json::json!("in_progress"))), - ("execution", None), - ("execution", Some(serde_json::json!("server"))), - ("namespace", Some(serde_json::json!("tools"))), - ]; - - for (field, replacement) in invalid_fields { - let mut item = valid.clone(); - let case = format!("native {field}: {replacement:?}"); - match replacement { - Some(value) => item[field] = value, - None => { - item.as_object_mut().unwrap().remove(field); - } - } - assert_invalid_blocking_tool_search_item(&case, &item); - } - } - - #[test] - fn blocking_rejects_malformed_synthetic_tool_search_calls_before_serde_defaults() { - let valid = serde_json::json!({ - "type": "function_call", - "id": "fc_search", - "call_id": "call_search", - "name": "tool_search", - "namespace": null, - "arguments": "{\"query\":\"weather\"}", - "status": "completed" - }); - let invalid_fields = [ - ("id", None), - ("id", Some(serde_json::json!(" "))), - ("call_id", None), - ("call_id", Some(serde_json::Value::Null)), - ("call_id", Some(serde_json::json!(" "))), - ("call_id", Some(serde_json::json!(7))), - ("arguments", None), - ("arguments", Some(serde_json::Value::Null)), - ("arguments", Some(serde_json::json!({}))), - ("arguments", Some(serde_json::json!("{"))), - ("arguments", Some(serde_json::json!("[]"))), - ("status", None), - ("status", Some(serde_json::Value::Null)), - ("status", Some(serde_json::json!(7))), - ("status", Some(serde_json::json!("in_progress"))), - ("namespace", Some(serde_json::json!("tools"))), - ]; - - for (field, replacement) in invalid_fields { - let mut item = valid.clone(); - let case = format!("synthetic {field}: {replacement:?}"); - match replacement { - Some(value) => item[field] = value, - None => { - item.as_object_mut().unwrap().remove(field); - } - } - assert_invalid_blocking_tool_search_item(&case, &item); - } - } - - #[test] - fn blocking_ordinary_function_named_tool_search_keeps_compatibility_defaults_when_inactive() { - let body = serde_json::json!({ - "id": "resp_ordinary", - "status": "completed", - "output": [{ - "type": "function_call", - "call_id": "call_ordinary", - "name": "tool_search", - "arguments": "{}", - "status": null - }] - }); - - let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) - .unwrap() - .with_tool_types(HashMap::new(), &HashSet::new()) - .unwrap(); - let [OutputItem::FunctionCall(call)] = accumulator.output.as_slice() else { - panic!("inactive ordinary function must retain generic function-call parsing") - }; - assert!(call.id.starts_with("fc_")); - assert_eq!(call.call_id, "call_ordinary"); - assert_eq!(call.name, "tool_search"); - assert_eq!(call.status, MessageStatus::Completed); - } - - #[test] - fn blocking_preserves_valid_native_and_synthetic_tool_search_calls() { - let native_body = serde_json::json!({ - "id": "resp_native", - "status": "completed", - "output": [{ - "type": "tool_search_call", - "id": "tsc_native", - "call_id": "call_native", - "execution": "client", - "namespace": null, - "arguments": {"query": "weather"}, - "status": "completed" - }] - }); - let native = ResponseAccumulator::from_json(&native_body.to_string(), None).unwrap(); - assert!(matches!(native.output.as_slice(), [OutputItem::ToolSearchCall(call)] if call.id == "tsc_native")); - - let synthetic_body = serde_json::json!({ - "id": "resp_synthetic", - "status": "completed", - "output": [{ - "type": "function_call", - "id": "fc_synthetic", - "call_id": "call_synthetic", - "name": "tool_search", - "namespace": null, - "arguments": "{\"query\":\"weather\"}", - "status": "completed" - }] - }); - let synthetic = ResponseAccumulator::from_json(&synthetic_body.to_string(), None) - .unwrap() - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .unwrap(); - assert!(matches!(synthetic.output.as_slice(), [OutputItem::ToolSearchCall(call)] - if call.id == "tsc_synthetic" && call.call_id == "call_synthetic")); - } - - #[test] - fn blocking_incomplete_response_discards_unfinished_tool_search_calls() { - let items = [ - serde_json::json!({ - "type": "tool_search_call", - "id": "tsc_native", - "call_id": "call_native", - "execution": "client", - "arguments": {}, - "status": "in_progress" - }), - serde_json::json!({ - "type": "tool_search_call", - "id": "tsc_native", - "call_id": "call_native", - "execution": "client", - "arguments": {}, - "status": "incomplete" - }), - serde_json::json!({ - "type": "function_call", - "id": "fc_synthetic", - "call_id": "call_synthetic", - "name": "tool_search", - "arguments": "{}", - "status": "in_progress" - }), - ]; - - for item in items { - let body = serde_json::json!({ - "id": "resp_incomplete", - "status": "incomplete", - "output": [item] - }); - let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) - .unwrap() - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .unwrap(); - assert!(accumulator.output.is_empty()); - } - - for response_status in ["incomplete", "error", "failed"] { - let body = serde_json::json!({ - "id": "resp_terminal", - "status": response_status, - "output": [{ - "type": "function_call", - "id": "fc_partial", - "call_id": "call_partial", - "name": "tool_search", - "arguments": "{\"query\":", - "status": "in_progress" - }] - }); - let accumulator = ResponseAccumulator::from_json(&body.to_string(), None) - .unwrap() - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .unwrap(); - assert!(accumulator.output.is_empty(), "{response_status}"); - } - } - - #[test] - fn blocking_completed_response_rejects_in_progress_synthetic_call_with_partial_arguments() { - let body = serde_json::json!({ - "id": "resp_completed", - "status": "completed", - "output": [{ - "type": "function_call", - "id": "fc_partial", - "call_id": "call_partial", - "name": "tool_search", - "arguments": "{\"query\":", - "status": "in_progress" - }] - }); - let error = ResponseAccumulator::from_json(&body.to_string(), None) - .and_then(|accumulator| { - accumulator.with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - }) - .expect_err("completed response must reject unfinished tool search"); - - assert!(error.is_invalid_upstream_tool_search()); - assert_eq!(error.http_status(), http::StatusCode::BAD_GATEWAY); - } - #[test] fn test_blocking_preserves_all_documented_mcp_call_statuses() { let cases: [(Option<&str>, Option); 3] = [ @@ -1815,9 +1334,6 @@ mod tests { name: Some("get_weather".into()), namespace: Some("mcp__weather".into()), call_id: Some("call_abc".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -1893,9 +1409,6 @@ mod tests { name: Some("search".into()), namespace: None, call_id: Some("call_1".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -1945,9 +1458,6 @@ mod tests { name: Some("get_weather".into()), namespace: None, call_id: Some("call_1".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -1972,9 +1482,6 @@ mod tests { name: Some("get_time".into()), namespace: None, call_id: Some("call_2".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2018,9 +1525,6 @@ mod tests { name: None, namespace: None, call_id: None, - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2044,9 +1548,6 @@ mod tests { name: Some("lookup".into()), namespace: None, call_id: Some("call_x".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2090,9 +1591,6 @@ mod tests { name: Some("old_name".into()), namespace: None, call_id: Some("old_call".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2188,9 +1686,6 @@ mod tests { name: Some("tool".into()), namespace: None, call_id: Some("c1".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2248,9 +1743,6 @@ mod tests { name: Some("partial".into()), namespace: None, call_id: Some("c1".into()), - execution: None, - status: None, - arguments: None, }, wire: WireEvent::new("test"), }); @@ -2310,70 +1802,6 @@ mod tests { assert_eq!(acc.usage.unwrap().total_tokens, 15); } - #[test] - fn test_native_tool_search_call_accumulates_as_first_class_item() { - let lines = vec![ - r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}}}"#.to_owned(), - r#"data: {"type":"response.output_item.done","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"completed","call_id":"call_search","execution":"client","arguments":{"query":"weather"}}}"#.to_owned(), - r#"data: {"type":"response.completed","response":{"id":"resp_native","status":"completed","usage":null}}"#.to_owned(), - ]; - - let acc = ResponseAccumulator::from_sse_lines(lines, None); - assert!(acc.processing_error.is_none()); - let [OutputItem::ToolSearchCall(call)] = acc.output.as_slice() else { - panic!("expected one native tool-search call"); - }; - assert_eq!(call.id, "tsc_native"); - assert_eq!(call.call_id, "call_search"); - assert_eq!(call.status, ToolSearchStatus::Completed); - assert_eq!( - call.arguments, - serde_json::json!({"query": "weather"}).as_object().unwrap().clone() - ); - } - - #[test] - fn failed_response_discards_unfinished_tool_search_items() { - let added_items = [ - r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_search","type":"function_call","status":"in_progress","call_id":"call_search","name":"tool_search","arguments":""}}"#, - r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}}}"#, - ]; - for added in added_items { - let mut acc = ResponseAccumulator::new("resp_failed".to_owned(), None) - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .unwrap(); - acc.process_sse_line(added); - acc.process_sse_line( - r#"data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","usage":null}}"#, - ); - - assert_eq!(acc.status, ResponseStatus::Error); - assert!(acc.output.is_empty()); - assert!(acc.processing_error.is_none()); - } - } - - #[test] - fn completed_response_rejects_unfinished_synthetic_tool_search() { - let mut acc = ResponseAccumulator::new("resp_invalid".to_owned(), None) - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .unwrap(); - acc.process_sse_line( - r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_search","type":"function_call","status":"in_progress","call_id":"call_search","name":"tool_search","arguments":""}}"#, - ); - acc.process_sse_line( - r#"data: {"type":"response.completed","response":{"id":"resp_invalid","status":"completed","usage":null}}"#, - ); - - assert!(acc.processing_error.is_some()); - } - #[test] fn test_custom_tool_call_accumulates_freeform_input() { let lines = vec![ diff --git a/crates/agentic-server-core/src/executor/compaction.rs b/crates/agentic-server-core/src/executor/compaction.rs index 8fb27698..daefbcfb 100644 --- a/crates/agentic-server-core/src/executor/compaction.rs +++ b/crates/agentic-server-core/src/executor/compaction.rs @@ -1,6 +1,6 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::persist::persist_prepared_turn; -use crate::executor::prepare::prepare_tool_search; +use crate::executor::prepare::prepare_request_tools; use crate::executor::rehydrate::rehydrate_conversation; use crate::executor::request::{ExecutionContext, RequestContext}; use crate::executor::upstream::fetch_blocking_payload; @@ -283,18 +283,23 @@ pub async fn compact_response( ); payload.previous_response_id = request.previous_response_id; let ctx = rehydrate_conversation(payload, exec_ctx).await?; - let mut ctx = prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler).await?; - let model = ctx.request().enriched_request.model.clone(); - let instructions = ctx.request().enriched_request.instructions.clone(); - let input = std::mem::replace( - &mut ctx.request_mut().enriched_request.input, - ResponsesInput::Items(Vec::new()), - ); + let (mut ctx, registry) = prepare_request_tools(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler).await?; + let model = ctx.enriched_request.model.clone(); + let instructions = ctx.enriched_request.instructions.clone(); + let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new())); let (output, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; - let response_id = ctx.request().response_id.clone(); - ctx.request_mut().new_input_items.clone_from(&output); - match persist_prepared_turn(ctx, Vec::new(), &exec_ctx.conv_handler, &exec_ctx.resp_handler).await { + let response_id = ctx.response_id.clone(); + ctx.new_input_items.clone_from(&output); + match persist_prepared_turn( + ctx, + registry, + Vec::new(), + &exec_ctx.conv_handler, + &exec_ctx.resp_handler, + ) + .await + { Ok(()) | Err(ExecutorError::Storage(crate::StorageError::NotConfigured)) => {} Err(error) => return Err(error), } diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index b012c3d2..127cbe38 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -25,11 +25,11 @@ use crate::events::EventFrame; use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::inference::DONE_MARKER; use crate::executor::persist::persist_if_needed; -use crate::executor::prepare::prepare_tool_search; +use crate::executor::prepare::prepare_request_tools; use crate::executor::rehydrate::rehydrate_conversation; -use crate::executor::request::{ExecutionContext, PreparedTurn, RequestContext}; +use crate::executor::request::{ExecutionContext, RequestContext}; use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload}; -use crate::tool::{ToolRegistry, mcp}; +use crate::tool::{ToolRegistry, mcp::handler::list_tools_output_item}; use crate::types::io::{InputItem, OutputItem, ResponseUsage, ResponsesInput, ToolChoice}; use crate::types::request_response::{IncompleteDetails, RequestPayload, ResponsePayload}; use crate::utils::common::utcnow_str; @@ -97,13 +97,14 @@ impl Drop for AbortOnDrop { } async fn run_until_gateway_tools_complete( - ctx: PreparedTurn, + ctx: RequestContext, + registry: ToolRegistry, exec_ctx: &ExecutionContext, auth: Option<&str>, stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, -) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { - if ctx.request().original_request.input.has_compaction_trigger() { +) -> ExecutorResult<(ResponsePayload, RequestContext, ToolRegistry)> { + if ctx.original_request.input.has_compaction_trigger() { let (payload, ctx) = run_compaction_trigger(ctx, exec_ctx, auth).await?; if let Some((stream_accumulator, stream_sender)) = stream.as_mut() { emit_response_start_events(&payload, stream_accumulator, stream_sender)?; @@ -111,29 +112,33 @@ async fn run_until_gateway_tools_complete( emit_gateway_start_events(&event_plans, stream_accumulator, stream_sender)?; emit_gateway_completed_events(&payload.output, &event_plans, stream_accumulator, stream_sender)?; } - return Ok((payload, ctx)); + return Ok((payload, ctx, registry)); } - run_gateway_tool_loop(ctx, exec_ctx, auth, stream_upstream, stream).await + run_gateway_tool_loop(ctx, registry, exec_ctx, auth, stream_upstream, stream).await } async fn run_gateway_tool_loop( - mut ctx: PreparedTurn, + mut ctx: RequestContext, + registry: ToolRegistry, exec_ctx: &ExecutionContext, auth: Option<&str>, stream_upstream: bool, mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, -) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { - let registry = build_request_tool_registry(&mut ctx, exec_ctx).await?; - let mut combined_output: Vec = registry +) -> ExecutorResult<(ResponsePayload, RequestContext, ToolRegistry)> { + let mut executors = exec_ctx.gateway_executors.request_scoped(); + let mut registry = registry + .build_prepared_with_handlers(ctx.enriched_request.tools.as_mut(), &mut executors) + .await?; + let mut combined_output: Vec<_> = registry .mcp_list_tools_items() .iter() - .map(mcp::handler::list_tools_output_item) + .map(list_tools_output_item) .collect(); let mut combined_usage = None; for round in 0..MAX_GATEWAY_TOOL_ROUNDS { - let compaction_usage = maybe_compact_context(ctx.request_mut(), exec_ctx, auth).await?; + let compaction_usage = maybe_compact_context(&mut ctx, exec_ctx, auth).await?; accumulate_usage(&mut combined_usage, compaction_usage); let output_offset = combined_output.len(); let (mut payload, deferred_stream_events): (ResponsePayload, Vec<_>) = if stream_upstream { @@ -141,7 +146,7 @@ async fn run_gateway_tool_loop( &ctx, exec_ctx, auth, - ®istry, + &mut registry, stream .as_mut() .map(|(accumulator, sender)| (&mut **accumulator, *sender)), @@ -150,19 +155,20 @@ async fn run_gateway_tool_loop( .await?; (stream_payload.payload, stream_payload.deferred_events) } else { - let payload = fetch_blocking_payload(ctx.request(), exec_ctx, auth, ®istry).await?; - (payload, Vec::new()) + ( + fetch_blocking_payload(&ctx, exec_ctx, auth, ®istry).await?, + Vec::new(), + ) }; registry.restore_final_payload_output(&mut payload.output); accumulate_usage(&mut combined_usage, payload.usage.take()); let current_output = std::mem::take(&mut payload.output); if matches!(payload.status.as_str(), "error" | "failed") { combined_output.extend(current_output); - finalize_loop(&mut payload, combined_output, combined_usage, &ctx); - return Ok((payload, ctx)); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx, ®istry); + return Ok((payload, ctx, registry)); } - let upstream_incomplete = payload.status == "incomplete"; - log_custom_tool_calls(¤t_output, &ctx.request().response_id); + log_custom_tool_calls(¤t_output, &ctx.response_id); let has_client_owned = has_client_owned_calls(¤t_output, ®istry); let gateway_results = execute_and_emit_round_output_calls( ¤t_output, @@ -175,17 +181,12 @@ async fn run_gateway_tool_loop( .map(|(accumulator, sender)| (&mut **accumulator, *sender)), ) .await?; - let public_output = public_output_items(¤t_output, ®istry, &gateway_results); - combined_output.extend(public_output); - - if upstream_incomplete { - append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); - append_tool_outputs( - ctx.request_mut(), - gateway_results.into_iter().map(|result| result.input_item).collect(), - ); - finalize_loop(&mut payload, combined_output, combined_usage, &ctx); - return Ok((payload, ctx)); + combined_output.extend(public_output_items(¤t_output, ®istry, &gateway_results)); + + if payload.status == "incomplete" { + record_gateway_round_input(&mut ctx, ¤t_output, ®istry, gateway_results); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx, ®istry); + return Ok((payload, ctx, registry)); } match classify_round(has_client_owned, &gateway_results, round, MAX_GATEWAY_TOOL_ROUNDS) { @@ -193,18 +194,14 @@ async fn run_gateway_tool_loop( // handed back to the caller. Gateway calls in the same turn are // still recorded so the returned conversation is complete. LoopDecision::RequiresClientAction => { - append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); - append_tool_outputs( - ctx.request_mut(), - gateway_results.into_iter().map(|result| result.input_item).collect(), - ); - finalize_loop(&mut payload, combined_output, combined_usage, &ctx); - return Ok((payload, ctx)); + record_gateway_round_input(&mut ctx, ¤t_output, ®istry, gateway_results); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx, ®istry); + return Ok((payload, ctx, registry)); } // No gateway work remains — this turn is the final response. LoopDecision::Done => { - finalize_loop(&mut payload, combined_output, combined_usage, &ctx); - return Ok((payload, ctx)); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx, ®istry); + return Ok((payload, ctx, registry)); } // Budget exhausted while the model was still requesting gateway // tools: surface the accumulated work as a partial @@ -212,25 +209,17 @@ async fn run_gateway_tool_loop( // The final round's gateway calls and outputs are recorded so a // continuation is not fed a dangling tool call. LoopDecision::Incomplete(reason) => { - append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); - append_tool_outputs( - ctx.request_mut(), - gateway_results.into_iter().map(|result| result.input_item).collect(), - ); - finalize_loop(&mut payload, combined_output, combined_usage, &ctx); + record_gateway_round_input(&mut ctx, ¤t_output, ®istry, gateway_results); + finalize_loop(&mut payload, combined_output, combined_usage, &ctx, ®istry); "incomplete".clone_into(&mut payload.status); payload.incomplete_details = Some(IncompleteDetails { reason: Some(reason) }); - return Ok((payload, ctx)); + return Ok((payload, ctx, registry)); } // Gateway tools ran and rounds remain; feed outputs back and loop. LoopDecision::Continue => { - ctx.request_mut().enriched_request.tool_choice = Some(ToolChoice::Auto); - append_output_items_to_input(&mut ctx.request_mut().enriched_request.input, ¤t_output); - append_gateway_calls_to_new_input(ctx.request_mut(), ¤t_output, ®istry); - append_tool_outputs( - ctx.request_mut(), - gateway_results.into_iter().map(|result| result.input_item).collect(), - ); + ctx.enriched_request.tool_choice = Some(ToolChoice::Auto); + append_output_items_to_input(&mut ctx.enriched_request.input, ¤t_output); + record_gateway_round_input(&mut ctx, ¤t_output, ®istry, gateway_results); } } } @@ -238,6 +227,16 @@ async fn run_gateway_tool_loop( unreachable!("the final round returns Done, RequiresClientAction, or Incomplete"); } +fn record_gateway_round_input( + ctx: &mut RequestContext, + output: &[OutputItem], + registry: &ToolRegistry, + results: Vec, +) { + append_gateway_calls_to_new_input(ctx, output, registry); + append_tool_outputs(ctx, results.into_iter().map(|result| result.input_item).collect()); +} + fn log_custom_tool_calls(output: &[OutputItem], response_id: &str) { for item in output { if let OutputItem::CustomToolCall(call) = item { @@ -252,42 +251,26 @@ fn log_custom_tool_calls(output: &[OutputItem], response_id: &str) { } } -async fn build_request_tool_registry( - ctx: &mut PreparedTurn, - exec_ctx: &ExecutionContext, -) -> ExecutorResult { - let mut executors = exec_ctx.gateway_executors.request_scoped(); - let mut registry = match ctx.request_mut().enriched_request.tools.as_mut() { - Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?, - None => ToolRegistry::default(), - }; - ctx.apply_tool_search_to_registry(&mut registry)?; - Ok(registry) -} - /// Codex CLI remote-compaction V2: the client appends a `compaction_trigger` /// item to the input and expects the server to run its own summarization turn /// and stream back exactly one `compaction` output item plus `response.completed`. /// The trigger never reaches the upstream model; the summary inference is a /// normal blocking call against the same backend as standalone compaction. async fn run_compaction_trigger( - mut ctx: PreparedTurn, + mut ctx: RequestContext, exec_ctx: &ExecutionContext, auth: Option<&str>, -) -> ExecutorResult<(ResponsePayload, PreparedTurn)> { - let model = ctx.request().enriched_request.model.clone(); - let instructions = ctx.request().enriched_request.instructions.clone(); - let input = std::mem::replace( - &mut ctx.request_mut().enriched_request.input, - ResponsesInput::Items(Vec::new()), - ); +) -> ExecutorResult<(ResponsePayload, RequestContext)> { + let model = ctx.enriched_request.model.clone(); + let instructions = ctx.enriched_request.instructions.clone(); + let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new())); let (mut compacted, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?; let Some(InputItem::Compaction(compaction)) = compacted.pop() else { unreachable!("compact_items always appends a compaction item"); }; - ctx.request_mut().new_input_items = compacted; + ctx.new_input_items = compacted; let mut payload = ResponsePayload { - id: ctx.request().response_id.clone(), + id: ctx.response_id.clone(), object: "response".to_owned(), created_at: utcnow_str(), model, @@ -296,13 +279,13 @@ async fn run_compaction_trigger( usage: Some(usage), incomplete_details: None, error: None, - previous_response_id: ctx.request().original_request.previous_response_id.clone(), - conversation_id: ctx.request().conversation_id.clone(), + previous_response_id: ctx.original_request.previous_response_id.clone(), + conversation_id: ctx.conversation_id.clone(), instructions, tools: None, tool_choice: None, }; - ctx.request().inject_ids(&mut payload); + ctx.inject_ids(&mut payload); Ok((payload, ctx)) } @@ -311,7 +294,7 @@ async fn execute_and_emit_round_output_calls( registry: &ToolRegistry, output_offset: usize, deferred_events: Vec, - ctx: &PreparedTurn, + ctx: &RequestContext, stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult> { match (deferred_events.is_empty(), stream) { @@ -337,7 +320,7 @@ async fn execute_and_emit_ordered_output_calls( registry: &ToolRegistry, output_offset: usize, deferred_events: Vec, - ctx: &PreparedTurn, + ctx: &RequestContext, stream_accumulator: &mut GatewayStreamAccumulator, stream_sender: &mpsc::UnboundedSender, ) -> ExecutorResult> { @@ -428,34 +411,41 @@ fn finalize_loop( payload: &mut ResponsePayload, combined_output: Vec, combined_usage: Option, - ctx: &PreparedTurn, + ctx: &RequestContext, + registry: &ToolRegistry, ) { payload.output = combined_output; payload.usage = combined_usage; - ctx.request().inject_ids(payload); - if let Some(tools) = ctx.tool_search_response_tools() { + ctx.inject_ids(payload); + if let Some(tools) = registry.tool_search_response_tools() { payload.tools = Some(tools); - payload.tool_choice = Some(ctx.request().enriched_request.tool_choice.clone().unwrap_or_default()); + payload.tool_choice = Some(ctx.enriched_request.tool_choice.clone().unwrap_or_default()); } } async fn run_blocking( - ctx: PreparedTurn, + ctx: RequestContext, + registry: ToolRegistry, exec_ctx: &ExecutionContext, auth: Option<&str>, ) -> ExecutorResult { - let (payload, ctx) = run_until_gateway_tools_complete(ctx, exec_ctx, auth, false, None).await?; + let (payload, ctx, registry) = run_until_gateway_tools_complete(ctx, registry, exec_ctx, auth, false, None).await?; let ch = exec_ctx.conv_handler.clone(); let rh = exec_ctx.resp_handler.clone(); - persist_if_needed(payload.clone(), ctx, ch, rh).await?; + persist_if_needed(payload.clone(), ctx, registry, ch, rh).await?; Ok(payload) } -fn run_stream(ctx: PreparedTurn, exec_ctx: Arc, auth: Option) -> BoxStream { +fn run_stream( + ctx: RequestContext, + registry: ToolRegistry, + exec_ctx: Arc, + auth: Option, +) -> BoxStream { Box::pin(stream! { - let failure_context = StreamFailureContext::from(ctx.request()); + let failure_context = StreamFailureContext::from(&ctx); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let exec_ctx_for_run = Arc::clone(&exec_ctx); let event_tx_for_run = event_tx.clone(); @@ -464,6 +454,7 @@ fn run_stream(ctx: PreparedTurn, exec_ctx: Arc, auth: Option, auth: Option { + Ok((Ok((payload, ctx, registry)), mut stream_accumulator)) => { while let Ok(event) = event_rx.try_recv() { yield consume_stream_event(event, &mut next_sequence_number); } @@ -513,7 +504,7 @@ fn run_stream(ctx: PreparedTurn, exec_ctx: Arc, auth: Option match terminal_chunk { Ok(chunk) => yield chunk, Err(e) => yield stream_accumulator.executor_error_chunk(&e), @@ -657,12 +648,18 @@ impl ExecuteRequest { "executor received responses request" ); let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?; - let ctx = prepare_tool_search(ctx, &self.exec_ctx.conv_handler, &self.exec_ctx.resp_handler).await?; - if ctx.request().original_request.stream { - Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth))) + let (ctx, registry) = + prepare_request_tools(ctx, &self.exec_ctx.conv_handler, &self.exec_ctx.resp_handler).await?; + if ctx.original_request.stream { + Ok(Either::Right(run_stream( + ctx, + registry, + self.exec_ctx, + self.client_auth, + ))) } else { Ok(Either::Left( - run_blocking(ctx, &self.exec_ctx, self.client_auth.as_deref()).await?, + run_blocking(ctx, registry, &self.exec_ctx, self.client_auth.as_deref()).await?, )) } } diff --git a/crates/agentic-server-core/src/executor/function_sse.rs b/crates/agentic-server-core/src/executor/function_sse.rs index bbed1c97..94fecd04 100644 --- a/crates/agentic-server-core/src/executor/function_sse.rs +++ b/crates/agentic-server-core/src/executor/function_sse.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use serde_json::Value; @@ -6,10 +6,8 @@ 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, tool_search}; -use crate::types::io::{OutputItem, ToolSearchCall}; -use crate::types::tools::ToolSearchStatus; -use crate::utils::common::{serialize_to_string, serialize_to_value}; +use crate::tool::ToolType; +use crate::utils::common::serialize_to_string; const MAX_PENDING_FUNCTION_BYTES: usize = 256 * 1024; @@ -18,7 +16,6 @@ enum FunctionCallShape { PublicFunction, GatewayOwned, Custom(CustomCallState), - ToolSearch, } #[derive(Debug)] @@ -54,38 +51,21 @@ pub(super) struct FunctionSseTranslator { pending_unnamed: HashMap, pending_bytes: usize, first_gateway_output_index: Option, - tool_search_enabled: bool, - withheld_function_names: HashSet, - upstream_terminal_failure: bool, } impl FunctionSseTranslator { pub(super) fn new(tool_types: HashMap) -> Self { - let tool_search_enabled = tool_types.get("tool_search") == Some(&ToolType::ToolSearch); Self { tool_types, - tool_search_enabled, ..Self::default() } } - pub(super) fn with_withheld_function_names(mut self, names: &HashSet) -> Self { - self.withheld_function_names.clone_from(names); - self - } - pub(super) fn translate( &mut self, frame: EventFrame, call: Option>, - tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { - if matches!( - frame.event_type, - SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete - ) { - self.upstream_terminal_failure = true; - } let mut translated = match &frame.payload { EventPayload::OutputItemAdded { item_id, @@ -93,7 +73,7 @@ impl FunctionSseTranslator { output_index, name: Some(name), .. - } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call, None), + } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call), EventPayload::OutputItemAdded { item_id: _, item_type: SSEItemType::FunctionCall, @@ -117,7 +97,7 @@ impl FunctionSseTranslator { 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, tool_search_call) + self.finish_call(item_id, name, *output_index, frame.clone(), call) } _ => Ok(FunctionSseTranslation { frames: vec![frame], @@ -128,157 +108,6 @@ impl FunctionSseTranslator { Ok(translated) } - pub(super) fn finish(&self) -> ExecutorResult<()> { - if !self.upstream_terminal_failure - && (self - .active - .values() - .any(|shape| matches!(shape, FunctionCallShape::ToolSearch)) - || (self.tool_search_enabled && !self.pending_unnamed.is_empty())) - { - return Err(tool_search::invalid_upstream_search_call().into()); - } - Ok(()) - } - - pub(super) fn unfinished_search_item_ids(&self) -> HashSet<&str> { - let mut item_ids = HashSet::new(); - if self.tool_search_enabled { - item_ids.extend(self.pending_unnamed.values().flat_map(|pending| { - pending.frames.iter().filter_map(|frame| match &frame.payload { - EventPayload::OutputItemAdded { item_id, .. } if !item_id.is_empty() => Some(item_id.as_str()), - _ => None, - }) - })); - } - item_ids - } - - pub(super) fn validate_before_accumulation( - &mut self, - frame: &EventFrame, - call: Option>, - ) -> ExecutorResult<()> { - self.validate_withheld_function_names(frame)?; - match &frame.payload { - EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - name: None, - .. - } => self.validate_pending_frame_before_accumulation(frame), - EventPayload::FunctionCallArgsDone { - arguments, - name, - output_index, - .. - } if self.tool_type(name) == ToolType::ToolSearch - || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch)) => - { - if arguments.len() > MAX_PENDING_FUNCTION_BYTES { - return Err(tool_search::invalid_upstream_search_call().into()); - } - Ok(()) - } - EventPayload::OutputItemDone { - item_type: SSEItemType::FunctionCall, - output_index, - item, - .. - } if item - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| self.tool_type(name) == ToolType::ToolSearch) - || matches!(self.active.get(output_index), Some(FunctionCallShape::ToolSearch)) => - { - if item - .get("arguments") - .and_then(Value::as_str) - .is_some_and(|arguments| arguments.len() > MAX_PENDING_FUNCTION_BYTES) - { - return Err(tool_search::invalid_upstream_search_call().into()); - } - Ok(()) - } - EventPayload::FunctionCallArgsDelta { - delta, output_index, .. - } => { - let Some(shape) = self.active.get_mut(output_index) else { - return self.validate_pending_frame_before_accumulation(frame); - }; - match shape { - FunctionCallShape::Custom(_) => { - let current = call.map_or(0, |call| call.arguments().len()); - ensure_function_call_size_for(current, delta.len()) - } - FunctionCallShape::ToolSearch => { - ensure_function_call_size_for(call.map_or(0, |call| call.arguments().len()), delta.len()) - .map_err(|_| tool_search::invalid_upstream_search_call().into()) - } - FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned => Ok(()), - } - } - _ => Ok(()), - } - } - - fn validate_withheld_function_names(&self, frame: &EventFrame) -> ExecutorResult<()> { - let terminal_has_withheld_call = frame.event_type == SSEEventType::ResponseCompleted - && frame - .wire - .rest - .get("response") - .and_then(|response| response.get("output")) - .and_then(Value::as_array) - .is_some_and(|output| { - output.iter().any(|item| { - item.get("type").and_then(Value::as_str) == Some("function_call") - && item - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| self.withheld_function_names.contains(name)) - }) - }); - let lifecycle_name = match &frame.payload { - EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - name: Some(name), - .. - } - | EventPayload::FunctionCallArgsDone { name, .. } => Some(name.as_str()), - EventPayload::OutputItemDone { - item_type: SSEItemType::FunctionCall, - item, - .. - } => item.get("name").and_then(Value::as_str), - _ => None, - }; - if terminal_has_withheld_call || lifecycle_name.is_some_and(|name| self.withheld_function_names.contains(name)) - { - return Err(tool_search::invalid_upstream_withheld_function_call().into()); - } - Ok(()) - } - - fn validate_pending_frame_before_accumulation(&self, 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(self.pending_limit_error(format!( - "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" - ))); - } - Ok(()) - } - - fn pending_limit_error(&self, message: String) -> ExecutorError { - if self.tool_search_enabled { - tool_search::invalid_upstream_search_call().into() - } else { - ExecutorError::StreamError(message) - } - } - fn start_call( &mut self, item_id: &str, @@ -286,7 +115,6 @@ impl FunctionSseTranslator { output_index: u32, original: Option, call: Option>, - tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { match self.tool_type(name) { ToolType::Custom => { @@ -321,30 +149,7 @@ impl FunctionSseTranslator { self.active.insert(output_index, FunctionCallShape::GatewayOwned); Ok(FunctionSseTranslation::default()) } - ToolType::ToolSearch => { - let started = if let Some(call) = call { - ToolSearchCall::started_from_function(call.item)? - } else { - let mut started = tool_search_call - .cloned() - .ok_or_else(tool_search::invalid_upstream_search_call)?; - started.arguments.clear(); - started.status = ToolSearchStatus::InProgress; - started - }; - let public_item = - serialize_to_value(&OutputItem::ToolSearchCall(started)).map_err(ExecutorError::JsonError)?; - self.active.insert(output_index, FunctionCallShape::ToolSearch); - Ok(FunctionSseTranslation { - frames: vec![tool_search_frame( - SSEEventType::OutputItemAdded, - output_index, - public_item, - )?], - defer_from_output_index: None, - }) - } - ToolType::Function | ToolType::CodexNamespace => { + ToolType::Function | ToolType::ToolSearch | ToolType::CodexNamespace => { self.active.insert(output_index, FunctionCallShape::PublicFunction); Ok(FunctionSseTranslation { frames: original.into_iter().collect(), @@ -366,9 +171,7 @@ impl FunctionSseTranslator { frames: vec![original], defer_from_output_index: None, }), - Some(FunctionCallShape::GatewayOwned | FunctionCallShape::ToolSearch) => { - Ok(FunctionSseTranslation::default()) - } + Some(FunctionCallShape::GatewayOwned) => Ok(FunctionSseTranslation::default()), Some(FunctionCallShape::Custom(state)) => { let frame = match call { Some(call) => incremental_custom_delta(state, call.arguments())?, @@ -391,10 +194,10 @@ impl FunctionSseTranslator { original: EventFrame, call: Option>, ) -> ExecutorResult { - let mut translated = self.resolve_pending(item_id, name, output_index, call, None)?; + let mut translated = self.resolve_pending(item_id, name, output_index, call)?; match self.active.get_mut(&output_index) { Some(FunctionCallShape::PublicFunction) | None => translated.frames.push(original), - Some(FunctionCallShape::GatewayOwned | FunctionCallShape::ToolSearch) => {} + Some(FunctionCallShape::GatewayOwned) => {} Some(FunctionCallShape::Custom(state)) => { if let Some(call) = call { translated.frames.extend(finish_custom_input(state, call.arguments())?); @@ -411,9 +214,8 @@ impl FunctionSseTranslator { output_index: u32, original: EventFrame, call: Option>, - tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { - let mut translated = self.resolve_pending(item_id, name, output_index, call, tool_search_call)?; + let mut translated = self.resolve_pending(item_id, name, output_index, call)?; match self.active.remove(&output_index) { Some(FunctionCallShape::PublicFunction) | None => translated.frames.push(original), Some(FunctionCallShape::GatewayOwned) => {} @@ -425,14 +227,6 @@ impl FunctionSseTranslator { translated.frames.push(custom_done_frame(&state, &call)?); } } - Some(FunctionCallShape::ToolSearch) => { - let public_call = tool_search_call.ok_or_else(tool_search::invalid_upstream_search_call)?; - let item = serialize_to_value(&OutputItem::ToolSearchCall(public_call.clone())) - .map_err(ExecutorError::JsonError)?; - translated - .frames - .push(tool_search_frame(SSEEventType::OutputItemDone, output_index, item)?); - } } Ok(translated) } @@ -443,7 +237,6 @@ impl FunctionSseTranslator { name: &str, output_index: u32, call: Option>, - tool_search_call: Option<&ToolSearchCall>, ) -> ExecutorResult { if self.active.contains_key(&output_index) { return Ok(FunctionSseTranslation::default()); @@ -459,24 +252,10 @@ impl FunctionSseTranslator { } ) }); - let start_item_id = original_added.and_then(|frame| match &frame.payload { - EventPayload::OutputItemAdded { item_id, .. } => Some(item_id.as_str()), - _ => None, - }); - let mut translated = self.start_call( - start_item_id.unwrap_or(item_id), - name, - output_index, - original_added.cloned(), - call, - tool_search_call, - )?; + let mut translated = self.start_call(item_id, name, output_index, original_added.cloned(), call)?; for frame in pending { - if let EventPayload::FunctionCallArgsDelta { - item_id, output_index, .. - } = &frame.payload - { + 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); } @@ -500,7 +279,7 @@ impl FunctionSseTranslator { .map_err(ExecutorError::JsonError)? .len(); if self.pending_bytes.saturating_add(bytes) > MAX_PENDING_FUNCTION_BYTES { - return Err(self.pending_limit_error(format!( + return Err(ExecutorError::StreamError(format!( "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" ))); } @@ -526,12 +305,6 @@ impl FunctionSseTranslator { } } -fn tool_search_frame(event_type: SSEEventType, output_index: u32, item: Value) -> ExecutorResult { - let mut frame = synthetic_event(event_type, [("item".to_owned(), item)])?; - frame.wire.output_index = Some(u64::from(output_index)); - Ok(frame) -} - fn custom_added_frame(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult { custom_frame( SSEEventType::OutputItemAdded, @@ -641,15 +414,6 @@ fn ensure_function_call_size(arguments: &str) -> ExecutorResult<()> { Ok(()) } -fn ensure_function_call_size_for(current: usize, additional: usize) -> ExecutorResult<()> { - if current.saturating_add(additional) > MAX_PENDING_FUNCTION_BYTES { - return Err(ExecutorError::StreamError(format!( - "function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" - ))); - } - Ok(()) -} - fn partial_custom_input(state: &mut CustomCallState, arguments: &str) -> ExecutorResult> { let input_start = if let Some(input_start) = state.input_start { input_start @@ -748,8 +512,6 @@ fn custom_input_start(arguments: &str) -> Option { mod tests { use super::*; use crate::executor::accumulator::ResponseAccumulator; - use crate::types::event::MessageStatus; - use crate::types::io::FunctionToolCall; fn sse(value: &Value) -> String { format!("data: {value}") @@ -762,395 +524,10 @@ mod tests { ) -> FunctionSseTranslation { accumulator .process_sse_line_with_translator(&sse(value), translator) - .unwrap_or_else(|error| panic!("translation succeeds for {value}: {error}")) + .expect("translation succeeds") .expect("SSE event") } - fn tool_search_accumulator(response_id: &str) -> ResponseAccumulator { - ResponseAccumulator::new(response_id.to_owned(), None) - .with_tool_types( - HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)]), - &HashSet::new(), - ) - .expect("tool-search accumulator configuration is valid") - } - - fn search_event_sequence(item_id: &str, call_id: &str, arguments: &str) -> [Value; 5] { - let split = arguments.len() / 2; - let (first, second) = arguments.split_at(split); - [ - serde_json::json!({ - "type": "response.output_item.added", "output_index": 0, - "item": {"id": item_id, "type": "function_call", "call_id": call_id, - "name": "tool_search", "arguments": "", "status": "in_progress"} - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 0, - "item_id": item_id, "call_id": call_id, "delta": first - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 0, - "item_id": item_id, "call_id": call_id, "delta": second - }), - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": item_id, "call_id": call_id, "name": "tool_search", "arguments": arguments - }), - serde_json::json!({ - "type": "response.output_item.done", "output_index": 0, - "item": {"id": item_id, "type": "function_call", "call_id": call_id, - "name": "tool_search", "arguments": arguments, "status": "completed"} - }), - ] - } - - #[test] - fn tool_search_stream_emits_only_public_added_and_done_with_stable_identity() { - let mut accumulator = tool_search_accumulator("resp_1"); - let mut translator = FunctionSseTranslator::new(HashMap::from([ - ("tool_search".to_owned(), ToolType::ToolSearch), - ("weather".to_owned(), ToolType::Function), - ])); - let arguments = r#"{"query":"weather"}"#; - let mut frames = Vec::new(); - - for (index, event) in search_event_sequence("fc_search", "call_search", arguments) - .into_iter() - .enumerate() - { - frames.extend(translate(&mut accumulator, &mut translator, &event).frames); - if index == 1 { - let ordinary = serde_json::json!({ - "type": "response.output_item.added", "output_index": 1, - "item": {"id": "fc_weather", "type": "function_call", "call_id": "call_weather", - "name": "weather", "arguments": "", "status": "in_progress"} - }); - frames.extend(translate(&mut accumulator, &mut translator, &ordinary).frames); - } - } - - assert_eq!( - frames.iter().map(|frame| frame.event_type).collect::>(), - [ - SSEEventType::OutputItemAdded, - SSEEventType::OutputItemAdded, - SSEEventType::OutputItemDone - ] - ); - let search_frames = frames - .iter() - .filter(|frame| frame.wire.output_index == Some(0)) - .collect::>(); - assert_eq!(search_frames.len(), 2); - assert_eq!(search_frames[0].wire.rest["item"]["type"], "tool_search_call"); - assert_eq!(search_frames[0].wire.rest["item"]["status"], "in_progress"); - assert_eq!(search_frames[0].wire.rest["item"]["arguments"], serde_json::json!({})); - assert_eq!(search_frames[1].wire.rest["item"]["type"], "tool_search_call"); - assert_eq!(search_frames[1].wire.rest["item"]["status"], "completed"); - assert_eq!( - search_frames[1].wire.rest["item"]["arguments"], - serde_json::json!({"query": "weather"}) - ); - assert_eq!(search_frames[0].wire.rest["item"]["id"], "tsc_search"); - assert_eq!( - search_frames[0].wire.rest["item"]["id"], - search_frames[1].wire.rest["item"]["id"] - ); - assert_eq!(search_frames[0].wire.rest["item"]["call_id"], "call_search"); - assert_eq!( - search_frames[0].wire.rest["item"]["call_id"], - search_frames[1].wire.rest["item"]["call_id"] - ); - assert_eq!(frames[1].wire.rest["item"]["type"], "function_call"); - - let blocking = OutputItem::ToolSearchCall( - ToolSearchCall::try_from(&FunctionToolCall { - id: "fc_search".to_owned(), - call_id: "call_search".to_owned(), - name: "tool_search".to_owned(), - namespace: None, - arguments: arguments.to_owned(), - status: MessageStatus::Completed, - }) - .expect("blocking translation uses the same typed conversion"), - ); - let blocking = serialize_to_value(&blocking).expect("blocking item serializes"); - let replay: crate::types::io::InputItem = - serde_json::from_value(blocking.clone()).expect("public item replays"); - let replay = serialize_to_value(&replay).expect("replay serializes"); - assert_eq!(blocking, search_frames[1].wire.rest["item"]); - assert_eq!(replay, search_frames[1].wire.rest["item"]); - } - - #[test] - fn tool_search_stream_rejects_malformed_arguments_and_empty_call_id() { - for (call_id, arguments) in [("call_search", "[1]"), ("call_search", "{"), ("", "{}")] { - let mut accumulator = tool_search_accumulator("resp_1"); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let mut error = None; - for event in search_event_sequence("fc_search", call_id, arguments) { - match accumulator.process_sse_line_with_translator(&sse(&event), &mut translator) { - Ok(_) => {} - Err(found) => { - error = Some(found); - break; - } - } - } - assert!( - error - .expect("invalid synthetic search stream must fail") - .to_string() - .contains("invalid tool-search call") - ); - } - } - - #[test] - fn tool_search_stream_rejects_premature_eof() { - let mut accumulator = tool_search_accumulator("resp_1"); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let added = &search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#)[0]; - translate(&mut accumulator, &mut translator, added); - - assert!( - translator.finish().is_err(), - "an unfinished search call must fail at EOF" - ); - } - - #[test] - fn tool_search_stream_accepts_authoritative_done_without_argument_deltas() { - let mut accumulator = tool_search_accumulator("resp_done_only"); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let mut events = search_event_sequence("fc_search", "call_search", r#"{"query":"weather"}"#); - events[0] - .get_mut("item") - .and_then(Value::as_object_mut) - .expect("added function item") - .remove("name"); - - let frames = [0, 4] - .into_iter() - .flat_map(|index| translate(&mut accumulator, &mut translator, &events[index]).frames) - .collect::>(); - - assert_eq!( - frames.iter().map(|frame| frame.event_type).collect::>(), - [SSEEventType::OutputItemAdded, SSEEventType::OutputItemDone] - ); - assert_eq!( - frames[1].wire.rest["item"]["arguments"], - serde_json::json!({"query": "weather"}) - ); - assert!(translator.finish().is_ok()); - } - - #[test] - fn tool_search_stream_accepts_omitted_done_name_for_active_call() { - let mut accumulator = tool_search_accumulator("resp_omitted_done_name"); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let arguments = r#"{"query": "add numbers"}"#; - let events = [ - serde_json::json!({ - "type": "response.output_item.added", "output_index": 1, - "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", - "name": "tool_search", "arguments": "", "status": "in_progress"} - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 1, - "item_id": "fc_search", "delta": "{}" - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 1, - "item_id": "fc_search", "delta": "{\"query\": \"" - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 1, - "item_id": "fc_search", "delta": "add numbers" - }), - serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": 1, - "item_id": "fc_search", "delta": "\"}" - }), - serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 1, - "item_id": "fc_search", "arguments": arguments - }), - serde_json::json!({ - "type": "response.output_item.done", "output_index": 1, - "item": {"id": "fc_search", "type": "function_call", "call_id": "call_search", - "name": "tool_search", "arguments": arguments, "status": "completed"} - }), - ]; - - let frames = events - .iter() - .flat_map(|event| translate(&mut accumulator, &mut translator, event).frames) - .collect::>(); - - assert_eq!( - frames.iter().map(|frame| frame.event_type).collect::>(), - [SSEEventType::OutputItemAdded, SSEEventType::OutputItemDone] - ); - assert_eq!(frames[0].wire.output_index, Some(1)); - assert_eq!(frames[1].wire.output_index, Some(1)); - assert_eq!(frames[0].wire.rest["item"]["id"], "tsc_search"); - assert_eq!(frames[0].wire.rest["item"]["call_id"], "call_search"); - assert_eq!(frames[0].wire.rest["item"]["execution"], "client"); - assert_eq!(frames[0].wire.rest["item"]["status"], "in_progress"); - assert_eq!(frames[1].wire.rest["item"]["id"], "tsc_search"); - assert_eq!(frames[1].wire.rest["item"]["call_id"], "call_search"); - assert_eq!(frames[1].wire.rest["item"]["execution"], "client"); - assert_eq!(frames[1].wire.rest["item"]["status"], "completed"); - assert_eq!( - frames[1].wire.rest["item"]["arguments"], - serde_json::json!({"query": "add numbers"}) - ); - assert!(translator.finish().is_ok()); - } - - #[test] - fn unfinished_search_ids_include_pending_candidates_with_other_loaded_tools_only_until_resolved() { - let tool_types = HashMap::from([ - ("tool_search".to_owned(), ToolType::ToolSearch), - ("weather".to_owned(), ToolType::Function), - ]); - let unnamed = serde_json::json!({ - "type": "response.output_item.added", "output_index": 0, - "item": {"id": "fc_candidate", "type": "function_call", "call_id": "call_candidate", - "arguments": "", "status": "in_progress"} - }); - - let mut pending_accumulator = tool_search_accumulator("resp_pending"); - let mut pending_translator = FunctionSseTranslator::new(tool_types.clone()); - translate(&mut pending_accumulator, &mut pending_translator, &unnamed); - assert_eq!( - pending_translator.unfinished_search_item_ids(), - HashSet::from(["fc_candidate"]) - ); - - let mut ordinary_accumulator = ResponseAccumulator::new("resp_ordinary".to_owned(), None); - let mut ordinary_translator = FunctionSseTranslator::new(tool_types); - translate(&mut ordinary_accumulator, &mut ordinary_translator, &unnamed); - let resolved = serde_json::json!({ - "type": "response.function_call_arguments.done", "output_index": 0, - "item_id": "fc_candidate", "call_id": "call_candidate", "name": "weather", - "arguments": "{}" - }); - translate(&mut ordinary_accumulator, &mut ordinary_translator, &resolved); - assert!(ordinary_translator.unfinished_search_item_ids().is_empty()); - } - - #[test] - fn upstream_failure_may_terminate_an_incomplete_search_without_false_completion() { - let mut accumulator = tool_search_accumulator("resp_1"); - let mut translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let added = &search_event_sequence("fc_search", "call_search", "{}")[0]; - translate(&mut accumulator, &mut translator, added); - let failed = serde_json::json!({ - "type": "response.failed", - "response": { - "id": "upstream_failed", "status": "failed", "usage": null, - "error": {"code": "provider_failure", "message": "provider stopped"}, - "incomplete_details": {"reason": "upstream_error"} - } - }); - let translated = translate(&mut accumulator, &mut translator, &failed); - - assert_eq!(translated.frames.len(), 1); - assert_eq!(translated.frames[0].event_type, SSEEventType::ResponseFailed); - assert!(translator.finish().is_ok()); - } - - #[test] - fn pending_function_stream_state_has_aggregate_byte_limit() { - let mut bytes_accumulator = ResponseAccumulator::new("resp_2".to_owned(), None); - let mut bytes_translator = FunctionSseTranslator::new(HashMap::new()); - let mut byte_error = None; - for output_index in 0..128 { - let unnamed = serde_json::json!({ - "type": "response.output_item.added", "output_index": output_index, - "item": {"id": format!("fc_bytes_{output_index}"), "type": "function_call", - "call_id": format!("call_bytes_{output_index}"), "arguments": "", "status": "in_progress"} - }); - if let Err(error) = - bytes_accumulator.process_sse_line_with_translator(&sse(&unnamed), &mut bytes_translator) - { - byte_error = Some(error); - break; - } - let delta = serde_json::json!({ - "type": "response.function_call_arguments.delta", "output_index": output_index, - "item_id": format!("fc_bytes_{output_index}"), "delta": "x".repeat(4 * 1024) - }); - match bytes_accumulator.process_sse_line_with_translator(&sse(&delta), &mut bytes_translator) { - Ok(_) => {} - Err(error) => { - byte_error = Some(error); - break; - } - } - } - assert!( - byte_error - .expect("aggregate pending bytes must be bounded") - .to_string() - .contains("unnamed function-call SSE exceeded") - ); - } - - #[test] - fn tool_search_argument_buffer_accepts_exact_limit_and_rejects_one_more_byte() { - let prefix = r#"{"query":""#; - let suffix = r#""}"#; - let exact_arguments = format!( - "{prefix}{}{suffix}", - "x".repeat(MAX_PENDING_FUNCTION_BYTES - prefix.len() - suffix.len()) - ); - assert_eq!(exact_arguments.len(), MAX_PENDING_FUNCTION_BYTES); - - let mut exact_accumulator = tool_search_accumulator("resp_exact"); - let mut exact_translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let exact_events = search_event_sequence("fc_exact", "call_exact", &exact_arguments); - translate(&mut exact_accumulator, &mut exact_translator, &exact_events[0]); - assert!( - exact_accumulator - .process_sse_line_with_translator(&sse(&exact_events[1]), &mut exact_translator) - .is_ok() - ); - assert!( - exact_accumulator - .process_sse_line_with_translator(&sse(&exact_events[2]), &mut exact_translator) - .is_ok() - ); - - let over_arguments = format!("{exact_arguments}x"); - let mut over_accumulator = tool_search_accumulator("resp_over"); - let mut over_translator = - FunctionSseTranslator::new(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); - let over_events = search_event_sequence("fc_over", "call_over", &over_arguments); - translate(&mut over_accumulator, &mut over_translator, &over_events[0]); - assert!( - over_accumulator - .process_sse_line_with_translator(&sse(&over_events[1]), &mut over_translator) - .is_ok() - ); - assert!( - over_accumulator - .process_sse_line_with_translator(&sse(&over_events[2]), &mut over_translator) - .expect_err("one byte beyond the search-call limit must fail") - .to_string() - .contains("invalid tool-search call") - ); - } - #[test] fn custom_function_arguments_are_emitted_incrementally() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); diff --git a/crates/agentic-server-core/src/executor/modes/conversation.rs b/crates/agentic-server-core/src/executor/modes/conversation.rs index 0c7d5b00..4a771ad6 100644 --- a/crates/agentic-server-core/src/executor/modes/conversation.rs +++ b/crates/agentic-server-core/src/executor/modes/conversation.rs @@ -121,7 +121,14 @@ impl ConversationHandler { /// Returns `ExecutorError` if `conversation_id` is absent on the context, /// the store is disabled, or the database operation fails. pub async fn execute_turn(&self, mut ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { - let metadata = ctx.take_response_metadata(); + let metadata = ResponseMetadata { + model: std::mem::take(&mut ctx.enriched_request.model), + previous_response_id: ctx.original_request.previous_response_id.take(), + effective_tools: ctx.enriched_request.tools.take(), + tool_search_loaded_tools: None, + effective_tool_choice: ctx.enriched_request.tool_choice.take().unwrap_or_default(), + effective_instructions: ctx.enriched_request.instructions.take(), + }; self.execute_turn_with_metadata(ctx, output_items, metadata).await } diff --git a/crates/agentic-server-core/src/executor/modes/response.rs b/crates/agentic-server-core/src/executor/modes/response.rs index c319748a..5d287ad4 100644 --- a/crates/agentic-server-core/src/executor/modes/response.rs +++ b/crates/agentic-server-core/src/executor/modes/response.rs @@ -69,7 +69,14 @@ impl ResponseHandler { /// # Errors /// Returns `ExecutorError` if the store is disabled or the database operation fails. pub async fn execute_turn(&self, mut ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { - let metadata = ctx.take_response_metadata(); + let metadata = ResponseMetadata { + model: std::mem::take(&mut ctx.enriched_request.model), + previous_response_id: ctx.original_request.previous_response_id.take(), + effective_tools: ctx.enriched_request.tools.take(), + tool_search_loaded_tools: None, + effective_tool_choice: ctx.enriched_request.tool_choice.take().unwrap_or_default(), + effective_instructions: ctx.enriched_request.instructions.take(), + }; self.execute_turn_with_metadata(ctx, output_items, metadata).await } diff --git a/crates/agentic-server-core/src/executor/persist.rs b/crates/agentic-server-core/src/executor/persist.rs index 6615cb6e..e607fa75 100644 --- a/crates/agentic-server-core/src/executor/persist.rs +++ b/crates/agentic-server-core/src/executor/persist.rs @@ -5,8 +5,10 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::modes::{ConversationHandler, ResponseHandler}; -use crate::executor::prepare::prepare_tool_search; -use crate::executor::request::{PreparedTurn, RequestContext}; +use crate::executor::prepare::prepare_request_tools; +use crate::executor::request::RequestContext; +use crate::storage::ResponseMetadata; +use crate::tool::ToolRegistry; use crate::types::event::ResponseStatus; use crate::types::io::OutputItem; use crate::types::request_response::ResponsePayload; @@ -21,12 +23,13 @@ pub(crate) fn should_persist(ctx: &RequestContext) -> bool { pub(crate) async fn persist_if_needed( payload: ResponsePayload, - ctx: PreparedTurn, + ctx: RequestContext, + registry: ToolRegistry, conv_handler: ConversationHandler, resp_handler: ResponseHandler, ) -> ExecutorResult<()> { - if should_persist(ctx.request()) { - persist_prepared_response(payload, ctx, conv_handler, resp_handler) + if should_persist(&ctx) { + persist_prepared_response(payload, ctx, registry, conv_handler, resp_handler) .await .map_err(|source| { error!(error = ?source, "failed to persist response"); @@ -60,13 +63,14 @@ pub async fn persist_response( return Ok(()); } - let ctx = prepare_tool_search(ctx, &conv_handler, &resp_handler).await?; - persist_prepared_turn(ctx, payload.output, &conv_handler, &resp_handler).await + let (ctx, registry) = prepare_request_tools(ctx, &conv_handler, &resp_handler).await?; + persist_prepared_turn(ctx, registry, payload.output, &conv_handler, &resp_handler).await } async fn persist_prepared_response( payload: ResponsePayload, - ctx: PreparedTurn, + ctx: RequestContext, + registry: ToolRegistry, conv_handler: ConversationHandler, resp_handler: ResponseHandler, ) -> ExecutorResult<()> { @@ -78,7 +82,7 @@ async fn persist_prepared_response( return Ok(()); } - persist_prepared_turn(ctx, payload.output, &conv_handler, &resp_handler).await + persist_prepared_turn(ctx, registry, payload.output, &conv_handler, &resp_handler).await } /// Persists one completed turn with the handler selected by its explicit conversation discriminator. @@ -91,18 +95,30 @@ pub async fn persist_turn( conv_handler: &ConversationHandler, resp_handler: &ResponseHandler, ) -> ExecutorResult<()> { - let ctx = prepare_tool_search(ctx, conv_handler, resp_handler).await?; - persist_prepared_turn(ctx, output_items, conv_handler, resp_handler).await + let (ctx, registry) = prepare_request_tools(ctx, conv_handler, resp_handler).await?; + persist_prepared_turn(ctx, registry, output_items, conv_handler, resp_handler).await } pub(crate) async fn persist_prepared_turn( - mut ctx: PreparedTurn, + mut ctx: RequestContext, + mut registry: ToolRegistry, output_items: Vec, conv_handler: &ConversationHandler, resp_handler: &ResponseHandler, ) -> ExecutorResult<()> { - let metadata = ctx.take_response_metadata(); - let ctx = ctx.into_request(); + let public_metadata = registry.take_tool_search_metadata(); + let mut metadata = ResponseMetadata { + model: std::mem::take(&mut ctx.enriched_request.model), + previous_response_id: ctx.original_request.previous_response_id.take(), + effective_tools: ctx.enriched_request.tools.take(), + tool_search_loaded_tools: None, + effective_tool_choice: ctx.enriched_request.tool_choice.take().unwrap_or_default(), + effective_instructions: ctx.enriched_request.instructions.take(), + }; + if let Some((effective_tools, loaded_tools)) = public_metadata { + metadata.effective_tools = effective_tools; + metadata.tool_search_loaded_tools = Some(loaded_tools); + } if ctx.original_request.conversation_id.is_some() { conv_handler .execute_turn_with_metadata(ctx, output_items, metadata) diff --git a/crates/agentic-server-core/src/executor/prepare.rs b/crates/agentic-server-core/src/executor/prepare.rs index c1cbfa62..e6fd817a 100644 --- a/crates/agentic-server-core/src/executor/prepare.rs +++ b/crates/agentic-server-core/src/executor/prepare.rs @@ -3,8 +3,8 @@ use crate::executor::error::ExecutorResult; use crate::executor::modes::{ConversationHandler, ResponseHandler}; use crate::executor::rehydrate::apply_effective_settings; -use crate::executor::request::{PreparedTurn, RequestContext}; -use crate::tool::PreparedToolSearch; +use crate::executor::request::RequestContext; +use crate::tool::ToolRegistry; use crate::types::tools::ResponsesTool; /// Prepare the tool-search projection for a fully rehydrated public request. @@ -12,16 +12,16 @@ use crate::types::tools::ResponsesTool; /// Compaction may remove the call/output pair that records which deferred /// definitions were loaded. Only that path performs a targeted metadata read; /// ordinary rehydration does not gain an additional storage query. -pub(crate) async fn prepare_tool_search( +pub(crate) async fn prepare_request_tools( mut ctx: RequestContext, conv_handler: &ConversationHandler, resp_handler: &ResponseHandler, -) -> ExecutorResult { +) -> ExecutorResult<(RequestContext, ToolRegistry)> { let restored_loaded_tools = restored_loaded_tools(&mut ctx, conv_handler, resp_handler).await?; let restore_only_declared = ctx.original_request.tools.is_some(); - let tool_search = - PreparedToolSearch::prepare(&mut ctx.enriched_request, &restored_loaded_tools, restore_only_declared)?; - Ok(PreparedTurn::new(ctx, tool_search)) + let registry = + ToolRegistry::prepare_request(&mut ctx.enriched_request, &restored_loaded_tools, restore_only_declared)?; + Ok((ctx, registry)) } async fn restored_loaded_tools( diff --git a/crates/agentic-server-core/src/executor/rehydrate.rs b/crates/agentic-server-core/src/executor/rehydrate.rs index 4824b0b6..0c1472d6 100644 --- a/crates/agentic-server-core/src/executor/rehydrate.rs +++ b/crates/agentic-server-core/src/executor/rehydrate.rs @@ -240,17 +240,17 @@ mod tests { if search.execution == crate::types::tools::ToolSearchExecution::Client )); - let ctx = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) - .await - .expect("explicit handler preparation accepts the rehydrated request"); + let (ctx, registry) = + crate::executor::prepare::prepare_request_tools(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) + .await + .expect("explicit handler preparation accepts the rehydrated request"); assert!( - ctx.tool_search() - .state() + registry + .tool_search_state() .is_some_and(crate::tool::ToolSearchState::is_active) ); let upstream = ctx - .request() .enriched_request .to_upstream_request(false) .expect("prepared tool-search request lowers at the upstream boundary"); @@ -287,9 +287,10 @@ mod tests { let ctx = rehydrate_conversation(request(None, Some("resp_search")), &exec_ctx) .await .expect("orphan history remains a valid rehydrated public shape"); - let error = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) - .await - .expect_err("explicit preparation rejects orphan stored public history"); + let error = + crate::executor::prepare::prepare_request_tools(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) + .await + .expect_err("explicit preparation rejects orphan stored public history"); assert!( matches!(error, ExecutorError::Tool(ToolError::Config(ref message)) if message.contains("orphan")), @@ -360,13 +361,13 @@ mod tests { let ctx = rehydrate_conversation(continuation, &exec_ctx) .await .expect("stored public call rehydrates before new output"); - let ctx = crate::executor::prepare::prepare_tool_search(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) - .await - .expect("stored continuation derives valid tool-search state"); + let (ctx, registry) = + crate::executor::prepare::prepare_request_tools(ctx, &exec_ctx.conv_handler, &exec_ctx.resp_handler) + .await + .expect("stored continuation derives valid tool-search state"); - let state = ctx - .tool_search() - .state() + let state = registry + .tool_search_state() .expect("valid state was prepared after rehydration"); assert!(state.is_active()); assert_eq!(state.loaded_public_tools().len(), 1); @@ -375,7 +376,7 @@ mod tests { crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather" )); let private_input = - serde_json::to_value(&ctx.request().enriched_request.input).expect("prepared private history serializes"); + serde_json::to_value(&ctx.enriched_request.input).expect("prepared private history serializes"); assert_eq!(private_input[0]["call_id"], "call_search_stored"); assert_eq!(private_input[1]["call_id"], "call_search_stored"); } diff --git a/crates/agentic-server-core/src/executor/request.rs b/crates/agentic-server-core/src/executor/request.rs index 98852560..158b8e22 100644 --- a/crates/agentic-server-core/src/executor/request.rs +++ b/crates/agentic-server-core/src/executor/request.rs @@ -3,18 +3,15 @@ use std::time::Duration; use crate::config::{Config, default_database_url}; use crate::error::Error; -use crate::executor::error::ExecutorResult; use crate::executor::modes::{ConversationHandler, ResponseHandler}; use crate::storage::backend::redact_database_urls; use crate::storage::{ - ConversationStore, ConversationVersion, DatabaseBackend, ResponseMetadata, ResponseStore, - create_pool_with_schema_and_configs, + ConversationStore, ConversationVersion, DatabaseBackend, ResponseStore, create_pool_with_schema_and_configs, }; -use crate::tool::{GatewayExecutor, GatewayExecutors, PreparedToolSearch, ToolRegistry}; +use crate::tool::{GatewayExecutor, GatewayExecutors}; use crate::types::io::InputItem; use crate::types::messages::GatewayToolMap; use crate::types::request_response::{RequestPayload, ResponsePayload}; -use crate::types::tools::ResponsesTool; /// Env var configuring client-tool → gateway-executor aliases for `/v1/messages` /// (e.g. `WebSearch=web_search`). Empty/unset means no aliases — client @@ -41,20 +38,6 @@ pub struct RequestContext { } impl RequestContext { - /// Construct generic response metadata for callers that do not need - /// tool-specific preparation. - #[must_use] - pub(crate) fn take_response_metadata(&mut self) -> ResponseMetadata { - ResponseMetadata { - model: std::mem::take(&mut self.enriched_request.model), - previous_response_id: self.original_request.previous_response_id.take(), - effective_tools: self.enriched_request.tools.take(), - tool_search_loaded_tools: None, - effective_tool_choice: self.enriched_request.tool_choice.take().unwrap_or_default(), - effective_instructions: self.enriched_request.instructions.take(), - } - } - /// Inject our `response_id` and `conversation_id` into a `ResponsePayload` /// received from the LLM (which carries the upstream's own IDs). pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) { @@ -66,63 +49,6 @@ impl RequestContext { } } -/// Fully prepared executor state for one Responses turn. -/// -/// The generic request context stays tool-agnostic; tool-search preparation is -/// carried beside it and is unavailable outside the executor crate. -#[derive(Debug)] -pub(crate) struct PreparedTurn { - request: RequestContext, - tool_search: PreparedToolSearch, -} - -impl PreparedTurn { - #[must_use] - pub(crate) const fn new(request: RequestContext, tool_search: PreparedToolSearch) -> Self { - Self { request, tool_search } - } - - #[must_use] - pub(crate) const fn request(&self) -> &RequestContext { - &self.request - } - - pub(crate) const fn request_mut(&mut self) -> &mut RequestContext { - &mut self.request - } - - pub(crate) fn apply_tool_search_to_registry(&self, registry: &mut ToolRegistry) -> ExecutorResult<()> { - self.tool_search.apply_to_registry(registry)?; - Ok(()) - } - - #[must_use] - pub(crate) fn tool_search_response_tools(&self) -> Option> { - self.tool_search.public_response_tools() - } - - #[must_use] - pub(crate) fn into_request(self) -> RequestContext { - self.request - } - - #[must_use] - pub(crate) fn take_response_metadata(&mut self) -> ResponseMetadata { - let public_metadata = self.tool_search.take_public_metadata(); - let mut metadata = self.request.take_response_metadata(); - if let Some((effective_tools, loaded_tools)) = public_metadata { - metadata.effective_tools = effective_tools; - metadata.tool_search_loaded_tools = Some(loaded_tools); - } - metadata - } - - #[cfg(test)] - pub(crate) fn tool_search(&self) -> &PreparedToolSearch { - &self.tool_search - } -} - /// Runtime dependencies passed into `execute()`. /// /// Owns the storage handlers, HTTP client, and LLM endpoint configuration. diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index a212df69..e851ecc9 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -12,15 +12,15 @@ use crate::executor::gateway::{ }; use crate::executor::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, emit_sse_frame}; use crate::executor::inference::{call_inference, fetch_response_json}; -use crate::executor::request::{ExecutionContext, PreparedTurn, RequestContext}; +use crate::executor::request::{ExecutionContext, RequestContext}; use crate::tool::ToolRegistry; use crate::types::request_response::ResponsePayload; -use crate::utils::common::{serialize_to_string, serialize_to_value}; +use crate::utils::common::serialize_to_string; const MAX_DEFERRED_STREAM_BYTES: usize = 256 * 1024; struct StreamEmitContext<'a> { - request: &'a PreparedTurn, + request: &'a RequestContext, registry: &'a ToolRegistry, sender: &'a tokio::sync::mpsc::UnboundedSender, accumulator: &'a mut GatewayStreamAccumulator, @@ -40,27 +40,30 @@ pub(super) async fn fetch_blocking_payload( ) -> ExecutorResult { let url = exec_ctx.responses_url(); // Non-streaming request: stream=false -> full JSON body -> from_json. + registry.ensure_request_prepared(&ctx.enriched_request)?; let upstream_request = ctx.enriched_request.to_upstream_request(false)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let body = fetch_response_json(upstream_json, &url, &exec_ctx.client, auth).await?; - let acc = ResponseAccumulator::from_json(&body, ctx.conversation_id.as_deref())? - .with_tool_types(registry.tool_type_map(), registry.withheld_function_names())?; + registry.validate_blocking_response(&body)?; + let acc = ResponseAccumulator::from_json(&body, ctx.conversation_id.as_deref())?; let mut payload = acc.finalize( &ctx.enriched_request.model, ctx.original_request.previous_response_id.as_deref(), ctx.original_request.instructions.as_deref(), ); + let status = payload.status.parse().unwrap_or_default(); + registry.normalize_response_output(&mut payload.output, status)?; ctx.inject_ids(&mut payload); Ok(payload) } pub(super) async fn fetch_stream_payload( - ctx: &PreparedTurn, + ctx: &RequestContext, exec_ctx: &ExecutionContext, auth: Option<&str>, - registry: &ToolRegistry, + registry: &mut ToolRegistry, mut stream: Option<( &mut GatewayStreamAccumulator, &tokio::sync::mpsc::UnboundedSender, @@ -68,7 +71,8 @@ pub(super) async fn fetch_stream_payload( output_offset: usize, ) -> ExecutorResult { let url = exec_ctx.responses_url(); - let upstream_request = ctx.request().enriched_request.to_upstream_request(true)?; + registry.ensure_request_prepared(&ctx.enriched_request)?; + let upstream_request = ctx.enriched_request.to_upstream_request(true)?; let upstream_json = serialize_to_string(&upstream_request).map_err(ExecutorError::JsonError)?; let mut line_stream = Box::pin(call_inference( upstream_json, @@ -78,26 +82,29 @@ pub(super) async fn fetch_stream_payload( exec_ctx.streaming_timeout, )); let tool_types = registry.tool_type_map(); - let mut acc = ResponseAccumulator::new(ctx.request().response_id.clone(), ctx.request().conversation_id.clone()) - .with_tool_types(tool_types.clone(), registry.withheld_function_names())?; - let mut function_sse = - FunctionSseTranslator::new(tool_types).with_withheld_function_names(registry.withheld_function_names()); + let mut acc = ResponseAccumulator::new(ctx.response_id.clone(), ctx.conversation_id.clone()); + let mut function_sse = FunctionSseTranslator::new(tool_types); + registry.begin_stream_response(); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); let mut deferred_bytes = 0; while let Some(line_result) = line_stream.next().await { let line = line_result?; + let adapted_line = registry.prepare_stream_line(&line)?; + let line = adapted_line.as_deref().unwrap_or(&line); if stream.is_none() { - if let Some(frame) = acc.process_sse_line(&line) { - log_upstream_failure(&frame, &ctx.request().response_id); + 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)? { + if let Some(translation) = acc.process_sse_line_with_translator(line, &mut function_sse)? { + let mut translation = translation; + translation.frames = registry.translate_stream_frames(translation.frames)?; let previous_defer_from_output_index = defer_from_output_index; defer_from_output_index = translation.defer_from_output_index.map(u64::from); for frame in &translation.frames { - log_upstream_failure(frame, &ctx.request().response_id); + log_upstream_failure(frame, &ctx.response_id); } if let Some((accumulator, sender)) = stream.as_mut() { let mut emit_ctx = StreamEmitContext { @@ -133,33 +140,16 @@ pub(super) async fn fetch_stream_payload( } } } - let unfinished_search_item_ids = function_sse - .unfinished_search_item_ids() - .into_iter() - .map(str::to_owned) - .collect::>(); - if stream.is_some() { - function_sse.finish()?; - } + registry.finish_stream_response()?; acc.finish_stream(); - if let Some(error) = acc.take_processing_error() { - return Err(error); - } let mut payload = acc.finalize( - &ctx.request().enriched_request.model, - ctx.request().original_request.previous_response_id.as_deref(), - ctx.request().original_request.instructions.as_deref(), + &ctx.enriched_request.model, + ctx.original_request.previous_response_id.as_deref(), + ctx.original_request.instructions.as_deref(), ); - if matches!(payload.status.as_str(), "error" | "failed" | "incomplete") { - payload.output.retain(|item| { - !matches!( - item, - crate::types::io::OutputItem::FunctionCall(call) - if unfinished_search_item_ids.contains(&call.id) - ) - }); - } - ctx.request().inject_ids(&mut payload); + let status = payload.status.parse().unwrap_or_default(); + registry.normalize_response_output(&mut payload.output, status)?; + ctx.inject_ids(&mut payload); Ok(StreamPayload { payload, deferred_events, @@ -196,7 +186,7 @@ fn log_upstream_failure(frame: &EventFrame, gateway_response_id: &str) { pub(super) fn emit_deferred_stream_events( deferred_events: Vec, - request: &PreparedTurn, + request: &RequestContext, registry: &ToolRegistry, accumulator: &mut GatewayStreamAccumulator, sender: &tokio::sync::mpsc::UnboundedSender, @@ -225,8 +215,8 @@ fn should_defer_stream_event(frame: &EventFrame, defer_from_output_index: Option } fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_>) -> ExecutorResult { - apply_context_response_ids(&mut frame.wire, emit_ctx.request.request()); - restore_public_tool_search_response_tools(&mut frame.wire, emit_ctx.request)?; + apply_context_response_ids(&mut frame.wire, emit_ctx.request); + emit_ctx.registry.restore_tool_search_response_tools(&mut frame.wire)?; emit_ctx.registry.restore_stream_event_wire(&mut frame.wire); let emitted = emit_ctx.accumulator.process_event(frame, emit_ctx.output_offset); if emitted { @@ -235,23 +225,6 @@ fn emit_stream_frame(frame: &mut EventFrame, emit_ctx: &mut StreamEmitContext<'_ Ok(emitted) } -fn restore_public_tool_search_response_tools(wire: &mut WireEvent, request: &PreparedTurn) -> ExecutorResult<()> { - let Some(response) = wire.rest.get_mut("response").and_then(Value::as_object_mut) else { - return Ok(()); - }; - if !response.contains_key("tools") { - return Ok(()); - } - let Some(tools) = request.tool_search_response_tools() else { - return Ok(()); - }; - response.insert( - "tools".to_owned(), - serialize_to_value(&tools).map_err(ExecutorError::JsonError)?, - ); - Ok(()) -} - fn emit_or_defer_stream_frame( mut frame: EventFrame, emit_ctx: &mut StreamEmitContext<'_>, @@ -344,7 +317,7 @@ mod tests { use crate::types::io::ResponsesInput; use crate::types::request_response::RequestPayload; - fn request_context() -> PreparedTurn { + fn request_context() -> RequestContext { let request = RequestPayload { model: "test".to_owned(), input: ResponsesInput::Text("hi".to_owned()), @@ -365,15 +338,14 @@ mod tests { cache_salt: None, context_management: None, }; - let request = RequestContext { + RequestContext { original_request: request.clone(), enriched_request: request, new_input_items: Vec::new(), response_id: "resp_test".to_owned(), conversation_id: None, conversation_version: None, - }; - PreparedTurn::new(request, crate::tool::PreparedToolSearch::default()) + } } fn frame(output_index: u64, payload: Value) -> EventFrame { diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index d98b4d08..703ff326 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -21,6 +21,5 @@ pub use function::FunctionHandler; pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput}; pub use mcp::{McpClient, McpClientPool, McpDiscoveredHandler, McpError, McpHandler, McpOperation, McpServerEntry}; pub use registry::{GatewayDispatchResult, ToolEntry, ToolRegistry, ToolType}; -pub(crate) use tool_search::PreparedToolSearch; pub use tool_search::{ToolSearchHandler, ToolSearchState}; pub use web_search::WebSearchHandler; diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 9067967b..1aa7e9ce 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -11,15 +11,20 @@ use super::executors::GatewayExecutors; use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; -use super::tool_search::{TOOL_SEARCH_NAME, insert_tool_search_entry}; +use super::tool_search::{ + TOOL_SEARCH_NAME, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, + validate_blocking_response, +}; use super::web_search::insert_web_search_entry; use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; use crate::events::WireEvent; +use crate::types::event::{MessageStatus, ResponseStatus}; use crate::types::io::OutputItem; use crate::types::io::output::{FunctionToolCall, McpListTools}; -use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool}; -use crate::utils::common::serialize_to_value_or_custom_default; +use crate::types::request_response::RequestPayload; +use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool, ToolSearchStatus}; +use crate::utils::common::{serialize_to_value, serialize_to_value_or_custom_default}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -168,13 +173,11 @@ fn insert_code_interpreter_entry( pub struct ToolRegistry { entries: HashMap, - /// Request-scoped public response translation is active even when a - /// declaration-free replay has no synthetic `tool_search` registry entry. - tool_search_translation_enabled: bool, + /// Prepared public/private tool-search projection for this request. + tool_search: Option>, - /// Exact model-visible function names known publicly but withheld from the - /// effective private tool set. - withheld_function_names: HashSet, + /// Per-round response adaptation for synthetic and native search calls. + tool_search_stream: Box, /// Built once from the declared tools, so final payload and streaming event /// restoration don't rebuild it on every call. @@ -193,6 +196,13 @@ pub struct ToolRegistry { } impl ToolRegistry { + /// Whether a public request needs tool-search preparation and therefore + /// must use the executor rather than transparent upstream pass-through. + #[must_use] + pub fn request_has_tool_search_state(request: &RequestPayload) -> bool { + super::tool_search::request_has_tool_search_state(request) + } + /// Build a registry from declared tools and attach gateway handlers for dispatchable tool types. /// /// # Errors @@ -290,8 +300,8 @@ impl ToolRegistry { Ok(Self { entries, - tool_search_translation_enabled: false, - withheld_function_names: HashSet::new(), + tool_search: None, + tool_search_stream: Box::default(), namespace_map, custom_tool_map, mcp_tool_map, @@ -299,6 +309,184 @@ impl ToolRegistry { }) } + /// Prepare the request's private inference projection, build the normal + /// dispatch table, and retain the public tool-search projection in this + /// request-scoped registry. + pub(crate) fn prepare_request( + request: &mut RequestPayload, + restored_loaded_tools: &[ResponsesTool], + restore_only_declared: bool, + ) -> Result { + let state = super::ToolSearchHandler::prepare_request(request, restored_loaded_tools, restore_only_declared)?; + let mut registry = Self::default(); + registry.install_tool_search_state(state.map(Box::new), false)?; + Ok(registry) + } + + /// Build the normal dispatch table after generic request preparation has + /// had a chance to short-circuit (for example, explicit compaction). + pub(crate) async fn build_prepared_with_handlers( + mut self, + tools: Option<&mut Vec>, + executors: &mut GatewayExecutors, + ) -> Result { + let mut registry = match tools { + Some(tools) => Self::build_with_handlers(tools, executors).await?, + None => Self::default(), + }; + registry.install_tool_search_state(self.tool_search.take(), true)?; + Ok(registry) + } + + fn install_tool_search_state( + &mut self, + state: Option>, + validate_private_routes: bool, + ) -> Result<(), ToolError> { + if let Some(state) = state { + if validate_private_routes { + self.validate_tool_search_state(&state)?; + } + self.tool_search = Some(state); + } + Ok(()) + } + + /// Public declarations to expose in response metadata. `Some([])` is + /// intentionally distinct from an inactive request. + #[must_use] + pub(crate) fn tool_search_response_tools(&self) -> Option> { + let state = self.tool_search.as_deref().filter(|state| state.is_active())?; + let mut tools = state.public_response_tools(); + for tool in &mut tools { + tool.sanitize_for_persistence(); + } + Some(tools) + } + + /// Move the public tool projection into response persistence metadata. + pub(crate) fn take_tool_search_metadata(&mut self) -> Option<(Option>, Vec)> { + self.tool_search + .as_deref_mut() + .filter(|state| state.is_active()) + .map(ToolSearchState::take_public_metadata) + } + + pub(crate) fn validate_blocking_response(&self, body: &str) -> Result<(), ToolError> { + let empty = HashSet::new(); + let state = self.tool_search.as_deref(); + validate_blocking_response( + body, + state.is_some_and(ToolSearchState::is_active), + state.map_or(&empty, ToolSearchState::withheld_function_names), + ) + } + + /// Ensure tool-search requests went through the request-scoped preparation seam. + pub(crate) fn ensure_request_prepared(&self, request: &RequestPayload) -> Result<(), ToolError> { + ensure_request_prepared(request, self.tool_search.is_some()) + } + + pub(crate) fn begin_stream_response(&mut self) { + self.tool_search_stream.reset(); + } + + pub(crate) fn prepare_stream_line(&mut self, line: &str) -> Result, ToolError> { + let empty = HashSet::new(); + let state = self.tool_search.as_deref(); + self.tool_search_stream.prepare_line( + line, + state.is_some_and(ToolSearchState::is_active), + state.map_or(&empty, ToolSearchState::withheld_function_names), + ) + } + + pub(crate) fn translate_stream_frames( + &mut self, + frames: Vec, + ) -> Result, ToolError> { + self.tool_search_stream.translate_frames(frames) + } + + pub(crate) fn finish_stream_response(&self) -> Result<(), ToolError> { + self.tool_search_stream.finish() + } + + pub(crate) fn normalize_response_output( + &self, + output: &mut Vec, + status: ResponseStatus, + ) -> Result<(), ToolError> { + let discard_unfinished = matches!(status, ResponseStatus::Error | ResponseStatus::Incomplete); + let mut normalized = Vec::with_capacity(output.len()); + for item in std::mem::take(output) { + match item { + OutputItem::FunctionCall(call) + if discard_unfinished && self.tool_search_stream.unfinished_item_ids().contains(&call.id) => {} + OutputItem::FunctionCall(call) + if self + .tool_search + .as_ref() + .is_some_and(|state| state.withheld_function_names().contains(&call.name)) => + { + return Err(super::tool_search::invalid_upstream_withheld_function_call()); + } + OutputItem::FunctionCall(call) + if call.name == TOOL_SEARCH_NAME + && (self.tool_search_stream.canonical_call(&call.id).is_some() + || (self.tool_search.as_deref().is_some_and(ToolSearchState::is_active) + && self + .entries + .get(TOOL_SEARCH_NAME) + .is_none_or(|entry| entry.tool_type == ToolType::ToolSearch))) => + { + if call.status != MessageStatus::Completed { + if discard_unfinished { + continue; + } + return Err(super::tool_search::invalid_upstream_search_call()); + } + let public = self + .tool_search_stream + .canonical_call(&call.id) + .cloned() + .map_or_else(|| crate::types::io::ToolSearchCall::try_from(&call), Ok)?; + normalized.push(OutputItem::ToolSearchCall(public)); + } + OutputItem::ToolSearchCall(call) if call.status != ToolSearchStatus::Completed => { + if !discard_unfinished { + return Err(super::tool_search::invalid_upstream_search_call()); + } + } + item => normalized.push(item), + } + } + *output = normalized; + Ok(()) + } + + pub(crate) fn restore_tool_search_response_tools(&self, wire: &mut WireEvent) -> Result<(), ToolError> { + let Some(response) = wire.rest.get_mut("response").and_then(Value::as_object_mut) else { + return Ok(()); + }; + if !response.contains_key("tools") { + return Ok(()); + } + let Some(tools) = self.tool_search_response_tools() else { + return Ok(()); + }; + response.insert( + "tools".to_owned(), + serialize_to_value(&tools).map_err(|_| super::tool_search::invalid_upstream_search_call())?, + ); + Ok(()) + } + + #[cfg(test)] + pub(crate) fn tool_search_state(&self) -> Option<&ToolSearchState> { + self.tool_search.as_deref() + } + #[must_use] pub fn lookup(&self, tool_name: &str) -> Option<&ToolEntry> { self.entries.get(tool_name) @@ -310,7 +498,7 @@ impl ToolRegistry { .iter() .map(|(name, entry)| (name.clone(), entry.tool_type)) .collect::>(); - if self.tool_search_translation_enabled { + if self.tool_search.as_deref().is_some_and(ToolSearchState::is_active) { tool_types .entry(TOOL_SEARCH_NAME.to_owned()) .or_insert(ToolType::ToolSearch); @@ -342,17 +530,15 @@ impl ToolRegistry { &self.mcp_list_tools_items } - /// Apply request-scoped tool-search translation and replay safeguards. - pub(crate) fn apply_tool_search_state(&mut self, state: &ToolSearchState) -> Result<(), ToolError> { - self.tool_search_translation_enabled = state.is_active(); - self.withheld_function_names.clone_from(state.withheld_function_names()); - if !self.tool_search_translation_enabled { + /// Validate the private dispatch table against prepared tool-search state. + fn validate_tool_search_state(&self, state: &ToolSearchState) -> Result<(), ToolError> { + if !state.is_active() { return Ok(()); } if self .entries .keys() - .any(|name| self.withheld_function_names.contains(name)) + .any(|name| state.withheld_function_names().contains(name)) { return Err(ToolError::Config( "a loaded tool collides with a withheld function name".to_owned(), @@ -372,11 +558,6 @@ impl ToolRegistry { Ok(()) } - #[must_use] - pub(crate) fn withheld_function_names(&self) -> &HashSet { - &self.withheld_function_names - } - pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) { CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref()); } @@ -515,7 +696,7 @@ mod tests { .expect("typed tool-search declaration builds normally"); registry - .apply_tool_search_state(&state) + .install_tool_search_state(Some(Box::new(state)), true) .expect("prepared tool-search entry is already classified"); let entry = registry.lookup("tool_search").expect("tool-search entry"); assert_eq!(entry.tool_type, ToolType::ToolSearch); @@ -560,7 +741,7 @@ mod tests { .expect("loaded function registry"); assert!(registry.lookup("tool_search").is_none()); registry - .apply_tool_search_state(&state) + .install_tool_search_state(Some(Box::new(state)), true) .expect("enable replay translation"); let valid: FunctionToolCall = serde_json::from_value(serde_json::json!({ @@ -623,15 +804,21 @@ mod tests { let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .expect("private registry"); - registry.apply_tool_search_state(&state).expect("state application"); + registry + .install_tool_search_state(Some(Box::new(state)), true) + .expect("state application"); assert!( !registry + .tool_search_state() + .expect("tool-search state") .withheld_function_names() .contains("agentic_ns__weather__ordinary_prefix_like") ); assert!( registry + .tool_search_state() + .expect("tool-search state") .withheld_function_names() .contains("agentic_ns__weather__forecast") ); diff --git a/crates/agentic-server-core/src/tool/tool_search.rs b/crates/agentic-server-core/src/tool/tool_search.rs index 21eb60bc..32775fe3 100644 --- a/crates/agentic-server-core/src/tool/tool_search.rs +++ b/crates/agentic-server-core/src/tool/tool_search.rs @@ -4,10 +4,12 @@ use std::fmt; use serde::Serialize; use serde_json::{Map, Value}; -use crate::types::event::MessageStatus; +use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType, normalize_sse_line}; +use crate::types::event::{MessageStatus, ResponseStatus}; +use crate::types::io::output::{BlockingFunctionToolCall, FunctionToolCall, OutputItem}; use crate::types::io::{ FunctionTool, FunctionToolResultMessage, InputFunctionToolCall, InputItem, InputToolSearchCall, ResponsesInput, - ToolCallOutput, ToolChoice, ToolSearchOutputMessage, + ToolCallOutput, ToolChoice, ToolSearchCall, ToolSearchOutputMessage, }; use crate::types::request_response::RequestPayload; use crate::types::tools::{ @@ -15,12 +17,13 @@ use crate::types::tools::{ ToolSearchToolParam, }; use crate::utils::common::{ - deserialize_from_value, serialize_to_string, serialize_to_value, serialize_to_value_or_custom_default, + deserialize_from_str, deserialize_from_value, serialize_to_string, serialize_to_value, + serialize_to_value_or_custom_default, }; use super::CodexNamespaceHandler; use super::handler::{ToolError, ToolHandler}; -use super::registry::{ToolEntry, ToolRegistry, ToolType}; +use super::registry::{ToolEntry, ToolType}; pub(crate) const TOOL_SEARCH_NAME: &str = "tool_search"; const DEFAULT_DESCRIPTION: &str = "Search the client tool catalog"; @@ -35,62 +38,6 @@ const DEFAULT_QUERY_DESCRIPTION: &str = "A concise description of the needed cap #[derive(Debug)] pub struct ToolSearchHandler; -/// Request-scoped tool-search preparation owned by the tool layer. -/// -/// The executor carries this value for the lifetime of one turn, while the -/// underlying public/private projection state remains an implementation detail -/// of the tool-search behavior. -#[derive(Debug, Default)] -pub(crate) struct PreparedToolSearch { - state: Option, -} - -impl PreparedToolSearch { - /// Build and consume the private inference projection for a fully - /// rehydrated request. - pub(crate) fn prepare( - request: &mut RequestPayload, - restored_loaded_tools: &[ResponsesTool], - restore_only_declared: bool, - ) -> Result { - let state = ToolSearchHandler::prepare_request(request, restored_loaded_tools, restore_only_declared)?; - Ok(Self { state }) - } - - /// Apply the derived model-visible routing safeguards to a request registry. - pub(crate) fn apply_to_registry(&self, registry: &mut ToolRegistry) -> Result<(), ToolError> { - if let Some(state) = &self.state { - registry.apply_tool_search_state(state)?; - } - Ok(()) - } - - /// Return the public declarations for a response envelope when tool search - /// is active. `Some([])` intentionally differs from an inactive request. - #[must_use] - pub(crate) fn public_response_tools(&self) -> Option> { - let state = self.state.as_ref().filter(|state| state.is_active())?; - let mut tools = state.public_response_tools(); - for tool in &mut tools { - tool.sanitize_for_persistence(); - } - Some(tools) - } - - /// Move the public persistence projection out of active state. - pub(crate) fn take_public_metadata(&mut self) -> Option<(Option>, Vec)> { - self.state - .as_mut() - .filter(|state| state.is_active()) - .map(ToolSearchState::take_public_metadata) - } - - #[cfg(test)] - pub(crate) fn state(&self) -> Option<&ToolSearchState> { - self.state.as_ref() - } -} - impl ToolSearchHandler { /// Prepare the private inference view from fully rehydrated public state. /// @@ -565,7 +512,7 @@ impl ToolSearchState { } fn validate_tool_search_request(request: &RequestPayload, input: &ResponsesInput) -> Result { - if !request.contains_tool_search_state_for_input(input) { + if !request_contains_tool_search_state(request, input) { return Ok(false); } @@ -598,6 +545,58 @@ fn validate_tool_search_request(request: &RequestPayload, input: &ResponsesInput Ok(true) } +fn request_contains_tool_search_state(request: &RequestPayload, input: &ResponsesInput) -> bool { + input_contains_tool_search_state(input) + || request + .tools + .as_deref() + .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) +} + + +pub(crate) fn request_has_tool_search_state(request: &RequestPayload) -> bool { + request_contains_tool_search_state(request, &request.input) +} + +fn input_contains_tool_search_state(input: &ResponsesInput) -> bool { + matches!( + input, + ResponsesInput::Items(items) + if items + .iter() + .any(|item| matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))) + ) +} + +fn tool_activates_tool_search(tool: &ResponsesTool) -> bool { + matches!(tool, ResponsesTool::ToolSearch(_)) || tool_has_deferred_definition(tool) +} + +fn tool_has_deferred_definition(tool: &ResponsesTool) -> bool { + match tool { + ResponsesTool::Function(function) => function.defer_loading == Some(true), + ResponsesTool::Namespace(namespace) => namespace.tools.iter().any( + |member| matches!(member, CodexNamespaceMember::Function(function) if function.defer_loading == Some(true)), + ), + ResponsesTool::ToolSearch(_) + | ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + | ResponsesTool::Custom(_) + | ResponsesTool::Unknown => false, + } +} + +pub(crate) fn ensure_request_prepared(request: &RequestPayload, prepared: bool) -> Result<(), ToolError> { + if request_has_tool_search_state(request) && !prepared { + return Err(ToolError::Config( + "tool_search requests require prepared request-scoped state before upstream conversion".to_owned(), + )); + } + Ok(()) +} + fn has_reserved_tool_search_name(tool: &ResponsesTool) -> bool { match tool { ResponsesTool::Function(function) => function.name.as_str() == TOOL_SEARCH_NAME, @@ -708,6 +707,542 @@ pub(crate) fn invalid_upstream_withheld_function_call() -> ToolError { ToolError::UpstreamWithheldFunctionCall } +const MAX_STREAM_FUNCTION_BYTES: usize = 256 * 1024; + +#[derive(Default)] +pub(crate) struct ToolSearchStreamState { + active: HashMap, + completed: HashMap, + canonical_calls: HashMap, + pending: HashMap, + argument_bytes: HashMap, + unfinished_item_ids: HashSet, + emitted_added: HashSet, + terminal_failure: bool, +} + +impl fmt::Debug for ToolSearchStreamState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ToolSearchStreamState") + .field("active_count", &self.active.len()) + .field("completed_count", &self.completed.len()) + .field("canonical_call_count", &self.canonical_calls.len()) + .field("pending_count", &self.pending.len()) + .field("argument_stream_count", &self.argument_bytes.len()) + .field("unfinished_item_count", &self.unfinished_item_ids.len()) + .field("emitted_added_count", &self.emitted_added.len()) + .field("terminal_failure", &self.terminal_failure) + .finish() + } +} + +impl ToolSearchStreamState { + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } + + pub(crate) fn prepare_line( + &mut self, + line: &str, + enabled: bool, + withheld_function_names: &HashSet, + ) -> Result, ToolError> { + let tracking_search = !self.active.is_empty() || !self.pending.is_empty() || !self.canonical_calls.is_empty(); + if !enabled && withheld_function_names.is_empty() && !tracking_search && !might_contain_tool_search_wire(line) { + return Ok(None); + } + let Some(mut frame) = normalize_sse_line(line) else { + return Ok(None); + }; + let native = frame + .wire + .rest + .get("item") + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + == Some("tool_search_call"); + let tracking_native = native || !self.active.is_empty() || !self.canonical_calls.is_empty(); + if !enabled && withheld_function_names.is_empty() && !tracking_native { + return Ok(None); + } + validate_withheld_stream_frame(&frame, withheld_function_names)?; + if matches!( + frame.event_type, + SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete + ) { + self.terminal_failure = true; + } + if !enabled && !tracking_native { + return Ok(None); + } + let native_call = native.then(|| adapt_native_stream_frame(&mut frame)).transpose()?; + self.validate_function_frame(&frame)?; + if let Some(call) = native_call { + let output_index = frame + .wire + .output_index + .and_then(|index| u32::try_from(index).ok()) + .unwrap_or_default(); + let mut started = call.clone(); + started.arguments.clear(); + started.status = ToolSearchStatus::InProgress; + self.active.insert(output_index, started); + self.unfinished_item_ids.insert(call.id.clone()); + if call.status == ToolSearchStatus::Completed { + self.canonical_calls.insert(call.id.clone(), call.clone()); + self.completed.insert(output_index, call.clone()); + self.unfinished_item_ids.remove(&call.id); + } + } + let canonicalized_output_index = event_output_index(&frame.payload) + .filter(|output_index| self.active.contains_key(output_index)) + .filter(|output_index| frame.wire.output_index != Some(u64::from(*output_index))); + if let Some(output_index) = canonicalized_output_index { + frame.wire.output_index = Some(u64::from(output_index)); + } + if native || canonicalized_output_index.is_some() { + let wire = serialize_to_string(&frame.wire).map_err(|_| invalid_upstream_search_call())?; + return Ok(Some(format!("data: {wire}"))); + } + Ok(None) + } + + fn validate_function_frame(&mut self, frame: &EventFrame) -> Result<(), ToolError> { + match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + output_index, + name, + item_id, + .. + } => { + let item = stream_item(frame)?; + match name.as_deref() { + Some(TOOL_SEARCH_NAME) => { + let call = strict_started_function(item)?; + self.start(*output_index, &call); + } + Some(_) => {} + None => { + self.pending.insert(*output_index, item.clone()); + if !item_id.is_empty() { + self.unfinished_item_ids.insert(item_id.clone()); + } + } + } + } + EventPayload::FunctionCallArgsDelta { + delta, output_index, .. + } if self.active.contains_key(output_index) || self.pending.contains_key(output_index) => { + let bytes = self.argument_bytes.entry(*output_index).or_default(); + *bytes = bytes.saturating_add(delta.len()); + if *bytes > MAX_STREAM_FUNCTION_BYTES { + return Err(invalid_upstream_search_call()); + } + } + EventPayload::FunctionCallArgsDone { + arguments, + name, + output_index, + .. + } => { + if name == TOOL_SEARCH_NAME || self.active.contains_key(output_index) { + if arguments.len() > MAX_STREAM_FUNCTION_BYTES || json_object(arguments).is_err() { + return Err(invalid_upstream_search_call()); + } + if !self.active.contains_key(output_index) { + let mut item = self + .pending + .remove(output_index) + .ok_or_else(invalid_upstream_search_call)?; + item.as_object_mut() + .ok_or_else(invalid_upstream_search_call)? + .insert("name".to_owned(), Value::String(TOOL_SEARCH_NAME.to_owned())); + let call = strict_started_function(&item)?; + self.start(*output_index, &call); + } + } else { + self.clear_pending(*output_index); + } + } + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + output_index, + item, + .. + } => { + let name = item.get("name").and_then(Value::as_str); + if name == Some(TOOL_SEARCH_NAME) || self.active.contains_key(output_index) { + if item + .get("arguments") + .and_then(Value::as_str) + .is_some_and(|arguments| arguments.len() > MAX_STREAM_FUNCTION_BYTES) + { + return Err(invalid_upstream_search_call()); + } + let function = strict_function_call(item)?; + if !self.active.contains_key(output_index) { + self.start(*output_index, &function); + } + if function.status == MessageStatus::Completed { + let public = ToolSearchCall::try_from(&function)?; + self.canonical_calls.insert(function.id.clone(), public.clone()); + self.completed.insert(*output_index, public); + self.unfinished_item_ids.remove(&function.id); + } + } else { + self.clear_pending(*output_index); + } + } + EventPayload::Response { .. } if frame.event_type == SSEEventType::ResponseCompleted => { + validate_terminal_output(frame, true)?; + } + EventPayload::Response { .. } + if matches!( + frame.event_type, + SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete + ) => + { + validate_terminal_output(frame, false)?; + } + _ => {} + } + Ok(()) + } + + fn start(&mut self, output_index: u32, function: &FunctionToolCall) { + if let Ok(public) = ToolSearchCall::started_from_function(function) { + self.unfinished_item_ids.insert(function.id.clone()); + self.active.insert(output_index, public); + self.pending.remove(&output_index); + } + } + + fn clear_pending(&mut self, output_index: u32) { + if let Some(item) = self.pending.remove(&output_index) + && let Some(item_id) = item.get("id").and_then(Value::as_str) + { + self.unfinished_item_ids.remove(item_id); + } + self.argument_bytes.remove(&output_index); + } + + pub(crate) fn translate_frames(&mut self, frames: Vec) -> Result, ToolError> { + let mut public = Vec::with_capacity(frames.len()); + for frame in frames { + let output_index = frame.wire.output_index.and_then(|index| u32::try_from(index).ok()); + let is_function_added = matches!( + frame.payload, + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + .. + } + ); + if is_function_added && output_index.is_some_and(|index| self.active.contains_key(&index)) { + let index = output_index.unwrap_or_default(); + if self.emitted_added.insert(index) { + public.push(public_stream_frame( + SSEEventType::OutputItemAdded, + index, + self.active.get(&index).ok_or_else(invalid_upstream_search_call)?, + )?); + } + continue; + } + if matches!( + frame.payload, + EventPayload::FunctionCallArgsDelta { .. } | EventPayload::FunctionCallArgsDone { .. } + ) && output_index.is_some_and(|index| self.active.contains_key(&index)) + { + continue; + } + let is_function_done = matches!( + frame.payload, + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + .. + } + ); + if is_function_done && output_index.is_some_and(|index| self.active.contains_key(&index)) { + let index = output_index.unwrap_or_default(); + if self.emitted_added.insert(index) { + public.push(public_stream_frame( + SSEEventType::OutputItemAdded, + index, + self.active.get(&index).ok_or_else(invalid_upstream_search_call)?, + )?); + } + if let Some(done) = self.completed.remove(&index) { + public.push(public_stream_frame(SSEEventType::OutputItemDone, index, &done)?); + } + self.active.remove(&index); + self.argument_bytes.remove(&index); + continue; + } + public.push(frame); + } + Ok(public) + } + + pub(crate) fn finish(&self) -> Result<(), ToolError> { + let has_unfinished_active = self + .active + .keys() + .any(|output_index| !self.completed.contains_key(output_index)); + if !self.terminal_failure && (has_unfinished_active || !self.pending.is_empty()) { + return Err(invalid_upstream_search_call()); + } + Ok(()) + } + + pub(crate) fn unfinished_item_ids(&self) -> &HashSet { + &self.unfinished_item_ids + } + + pub(crate) fn canonical_call(&self, internal_item_id: &str) -> Option<&ToolSearchCall> { + self.canonical_calls.get(internal_item_id) + } +} + +fn event_output_index(payload: &EventPayload) -> Option { + match payload { + EventPayload::OutputItemAdded { output_index, .. } + | EventPayload::OutputItemDone { output_index, .. } + | EventPayload::FunctionCallArgsDelta { output_index, .. } + | EventPayload::FunctionCallArgsDone { output_index, .. } => Some(*output_index), + _ => None, + } +} + +fn stream_item(frame: &EventFrame) -> Result<&Value, ToolError> { + frame.wire.rest.get("item").ok_or_else(invalid_upstream_search_call) +} + +fn strict_started_function(item: &Value) -> Result { + let function = strict_function_call(item)?; + if function.status != MessageStatus::InProgress || !function.arguments.is_empty() { + return Err(invalid_upstream_search_call()); + } + Ok(function) +} + +fn strict_function_call(item: &Value) -> Result { + BlockingFunctionToolCall::try_from(item).and_then(FunctionToolCall::try_from) +} + +fn json_object(arguments: &str) -> Result, ToolError> { + deserialize_from_str(arguments).map_err(|_| invalid_upstream_search_call()) +} + +fn adapt_native_stream_frame(frame: &mut EventFrame) -> Result { + let item = stream_item(frame)?; + let arguments = item + .get("arguments") + .and_then(Value::as_object) + .ok_or_else(invalid_upstream_search_call) + .and_then(serialize_stream_arguments)?; + let item = item.clone(); + let public = ToolSearchCall::from_blocking_output(item)?; + let status = match public.status { + ToolSearchStatus::Completed => MessageStatus::Completed, + ToolSearchStatus::InProgress | ToolSearchStatus::Incomplete => MessageStatus::InProgress, + }; + if frame.event_type == SSEEventType::OutputItemAdded + && (public.status != ToolSearchStatus::InProgress || !public.arguments.is_empty()) + { + return Err(invalid_upstream_search_call()); + } + let function = FunctionToolCall { + id: public.id.clone(), + call_id: public.call_id.clone(), + name: TOOL_SEARCH_NAME.to_owned(), + namespace: None, + arguments: if frame.event_type == SSEEventType::OutputItemAdded { + String::new() + } else { + arguments + }, + status, + }; + let item = + serialize_to_value(&OutputItem::FunctionCall(function.clone())).map_err(|_| invalid_upstream_search_call())?; + frame.wire.rest.insert("item".to_owned(), item.clone()); + frame.payload = match frame.event_type { + SSEEventType::OutputItemAdded => EventPayload::OutputItemAdded { + item_id: function.id, + item_type: SSEItemType::FunctionCall, + output_index: frame + .wire + .output_index + .and_then(|index| u32::try_from(index).ok()) + .unwrap_or_default(), + name: Some(function.name), + namespace: None, + call_id: Some(function.call_id), + }, + SSEEventType::OutputItemDone => EventPayload::OutputItemDone { + item_id: function.id, + item_type: SSEItemType::FunctionCall, + output_index: frame + .wire + .output_index + .and_then(|index| u32::try_from(index).ok()) + .unwrap_or_default(), + item, + }, + _ => return Err(invalid_upstream_search_call()), + }; + Ok(public) +} + +fn serialize_stream_arguments(arguments: &Map) -> Result { + let arguments = serialize_to_string(arguments).map_err(|_| invalid_upstream_search_call())?; + if arguments.len() > MAX_STREAM_FUNCTION_BYTES { + return Err(invalid_upstream_search_call()); + } + Ok(arguments) +} + +fn public_stream_frame( + event_type: SSEEventType, + output_index: u32, + call: &ToolSearchCall, +) -> Result { + let item = + serialize_to_value(&OutputItem::ToolSearchCall(call.clone())).map_err(|_| invalid_upstream_search_call())?; + let mut rest = Map::new(); + rest.insert("item".to_owned(), item); + let mut frame = EventFrame::synthetic(event_type, rest).ok_or_else(invalid_upstream_search_call)?; + frame.wire.output_index = Some(u64::from(output_index)); + Ok(frame) +} + +fn validate_withheld_stream_frame( + frame: &EventFrame, + withheld_function_names: &HashSet, +) -> Result<(), ToolError> { + let lifecycle_name = match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + name: Some(name), + .. + } + | EventPayload::FunctionCallArgsDone { name, .. } => Some(name.as_str()), + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + item, + .. + } => item.get("name").and_then(Value::as_str), + _ => None, + }; + let terminal_has_withheld = frame.event_type == SSEEventType::ResponseCompleted + && frame + .wire + .rest + .get("response") + .and_then(|response| response.get("output")) + .and_then(Value::as_array) + .is_some_and(|output| { + output.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call") + && item + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| withheld_function_names.contains(name)) + }) + }); + if terminal_has_withheld || lifecycle_name.is_some_and(|name| withheld_function_names.contains(name)) { + return Err(invalid_upstream_withheld_function_call()); + } + Ok(()) +} + +fn validate_terminal_output(frame: &EventFrame, completed: bool) -> Result<(), ToolError> { + let Some(output) = frame + .wire + .rest + .get("response") + .and_then(|response| response.get("output")) + .and_then(Value::as_array) + else { + return Ok(()); + }; + for item in output { + match item.get("type").and_then(Value::as_str) { + Some("tool_search_call") => { + let call = ToolSearchCall::from_blocking_output(item.clone())?; + serialize_stream_arguments(&call.arguments)?; + if completed && call.status != ToolSearchStatus::Completed { + return Err(invalid_upstream_search_call()); + } + } + Some("function_call") if item.get("name").and_then(Value::as_str) == Some(TOOL_SEARCH_NAME) => { + let call = strict_function_call(item)?; + if call.arguments.len() > MAX_STREAM_FUNCTION_BYTES { + return Err(invalid_upstream_search_call()); + } + if completed && call.status != MessageStatus::Completed { + return Err(invalid_upstream_search_call()); + } + } + _ => {} + } + } + Ok(()) +} + +pub(crate) fn validate_blocking_response( + body: &str, + tool_search_enabled: bool, + withheld_function_names: &HashSet, +) -> Result<(), ToolError> { + if !tool_search_enabled && withheld_function_names.is_empty() && !might_contain_tool_search_wire(body) { + return Ok(()); + } + let value: Value = match deserialize_from_str(body) { + Ok(value) => value, + Err(_) => return Ok(()), + }; + let status = value + .get("status") + .and_then(Value::as_str) + .map_or(ResponseStatus::Completed, |status| status.parse().unwrap_or_default()); + let discard_unfinished = matches!(status, ResponseStatus::Error | ResponseStatus::Incomplete); + for item in value.get("output").and_then(Value::as_array).into_iter().flatten() { + if item.get("type").and_then(Value::as_str) == Some("function_call") + && item + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| withheld_function_names.contains(name)) + { + return Err(invalid_upstream_withheld_function_call()); + } + match item.get("type").and_then(Value::as_str) { + Some("tool_search_call") => { + let call = ToolSearchCall::from_blocking_output(item.clone())?; + if !discard_unfinished && call.status != ToolSearchStatus::Completed { + return Err(invalid_upstream_search_call()); + } + } + Some("function_call") + if tool_search_enabled && item.get("name").and_then(Value::as_str) == Some(TOOL_SEARCH_NAME) => + { + let call = strict_function_call(item)?; + if !discard_unfinished && call.status != MessageStatus::Completed { + return Err(invalid_upstream_search_call()); + } + } + _ => {} + } + } + Ok(()) +} + +fn might_contain_tool_search_wire(wire: &str) -> bool { + wire.contains(TOOL_SEARCH_NAME) +} + fn index_initial_definitions( tools: &[ResponsesTool], definitions: &mut Vec, @@ -1565,6 +2100,7 @@ mod tests { use serde_json::json; use super::*; + use crate::tool::ToolRegistry; fn param(value: Value) -> ToolSearchToolParam { let ResponsesTool::ToolSearch(param) = serde_json::from_value(value).expect("valid tool_search declaration") @@ -1574,6 +2110,10 @@ mod tests { param } + fn sse_line(value: &Value) -> String { + format!("data: {value}") + } + #[test] fn handler_validates_and_normalizes_exactly_one_function() { let param = param(json!({ @@ -1622,6 +2162,178 @@ mod tests { ); } + #[test] + fn registry_requires_tool_search_preparation_before_upstream_conversion() { + let mut request: RequestPayload = serde_json::from_value(json!({ + "model": "test", + "input": "find weather", + "parallel_tool_calls": false, + "tools": [{"type": "tool_search", "execution": "client"}] + })) + .expect("request shape"); + + assert!(ToolRegistry::default().ensure_request_prepared(&request).is_err()); + let registry = ToolRegistry::prepare_request(&mut request, &[], false).expect("tool-search preparation"); + registry + .ensure_request_prepared(&request) + .expect("prepared request is ready for upstream conversion"); + } + + #[test] + fn ordinary_function_named_tool_search_does_not_require_preparation() { + let request: RequestPayload = serde_json::from_value(json!({ + "model": "test", + "input": "call the ordinary function", + "tools": [{"type": "function", "name": "tool_search"}] + })) + .expect("ordinary function request"); + + ToolRegistry::default() + .ensure_request_prepared(&request) + .expect("the reserved name applies only to active tool search"); + } + + #[test] + fn registry_strictly_validates_blocking_search_without_changing_inactive_functions() { + let mut request: RequestPayload = serde_json::from_value(json!({ + "model": "test", + "input": "find weather", + "parallel_tool_calls": false, + "tools": [{"type": "tool_search", "execution": "client"}] + })) + .expect("request shape"); + let registry = ToolRegistry::prepare_request(&mut request, &[], false).expect("tool-search preparation"); + let native = json!({ + "type": "tool_search_call", + "id": "tsc_1", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }); + let synthetic = json!({ + "type": "function_call", + "id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + }); + let malformed = [ + ("native missing id", { + let mut item = native.clone(); + item.as_object_mut().unwrap().remove("id"); + item + }), + ("native missing call_id", { + let mut item = native.clone(); + item.as_object_mut().unwrap().remove("call_id"); + item + }), + ("native missing arguments", { + let mut item = native.clone(); + item.as_object_mut().unwrap().remove("arguments"); + item + }), + ("native namespace", { + let mut item = native.clone(); + item["namespace"] = json!("catalog"); + item + }), + ("synthetic missing status", { + let mut item = synthetic.clone(); + item.as_object_mut().unwrap().remove("status"); + item + }), + ("synthetic null status", { + let mut item = synthetic.clone(); + item["status"] = Value::Null; + item + }), + ]; + + for (case, item) in malformed { + let body = json!({"status": "completed", "output": [item]}).to_string(); + assert!( + matches!( + registry.validate_blocking_response(&body), + Err(ToolError::InvalidUpstreamToolSearch) + ), + "{case}" + ); + } + + let partial = json!({ + "status": "incomplete", + "output": [{ + "type": "function_call", + "id": "fc_partial", + "call_id": "call_partial", + "name": "tool_search", + "arguments": "{\"query\":", + "status": "in_progress" + }] + }) + .to_string(); + registry + .validate_blocking_response(&partial) + .expect("unfinished search placeholder is allowed on an incomplete response"); + + let ordinary = json!({ + "status": "completed", + "output": [{"type": "function_call", "name": "tool_search", "arguments": "{}"}] + }) + .to_string(); + ToolRegistry::default() + .validate_blocking_response(&ordinary) + .expect("inactive ordinary function keeps generic compatibility defaults"); + } + + #[test] + fn stream_rejects_oversized_native_done_arguments() { + let mut stream = ToolSearchStreamState::default(); + let line = sse_line(&json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "tool_search_call", + "id": "provider-native-id", + "call_id": "call_search", + "execution": "client", + "status": "completed", + "arguments": {"query": "x".repeat(MAX_STREAM_FUNCTION_BYTES)} + } + })); + + assert!(matches!( + stream.prepare_line(&line, false, &HashSet::new()), + Err(ToolError::InvalidUpstreamToolSearch) + )); + } + + #[test] + fn stream_rejects_oversized_synthetic_done_arguments() { + let mut stream = ToolSearchStreamState::default(); + let arguments = format!("{{\"query\":\"{}\"}}", "x".repeat(MAX_STREAM_FUNCTION_BYTES)); + let line = sse_line(&json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "status": "completed", + "arguments": arguments + } + })); + + assert!(matches!( + stream.prepare_line(&line, true, &HashSet::new()), + Err(ToolError::InvalidUpstreamToolSearch) + )); + } + #[test] fn prepared_response_tools_remove_request_scoped_mcp_secrets_and_discovery() { let mut request: RequestPayload = serde_json::from_value(json!({ @@ -1647,8 +2359,8 @@ mod tests { })) .expect("request shape"); - let prepared = PreparedToolSearch::prepare(&mut request, &[], false).expect("tool-search preparation"); - let serialized = serde_json::to_value(prepared.public_response_tools().expect("active public tools")) + let prepared = ToolRegistry::prepare_request(&mut request, &[], false).expect("tool-search preparation"); + let serialized = serde_json::to_value(prepared.tool_search_response_tools().expect("active public tools")) .expect("public tools serialize"); let serialized = serialized.to_string(); diff --git a/crates/agentic-server-core/src/types/io/input.rs b/crates/agentic-server-core/src/types/io/input.rs index 8da1afd6..2990e6db 100644 --- a/crates/agentic-server-core/src/types/io/input.rs +++ b/crates/agentic-server-core/src/types/io/input.rs @@ -370,18 +370,6 @@ pub(crate) fn latest_compaction_window(items: &[InputItem]) -> Option bool { - matches!( - self, - Self::Items(items) - if items - .iter() - .any(|item| matches!(item, InputItem::ToolSearchCall(_) | InputItem::ToolSearchOutput(_))) - ) - } - #[must_use] pub fn contains_compaction(&self) -> bool { matches!(self, Self::Items(items) if items.iter().any(|item| matches!(item, InputItem::Compaction(_)))) @@ -516,25 +504,6 @@ mod tests { } } - #[test] - fn responses_input_detects_only_typed_tool_search_state() { - let search: ResponsesInput = serde_json::from_value(serde_json::json!([{ - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": [] - }])) - .expect("typed search state"); - let ordinary: ResponsesInput = serde_json::from_value(serde_json::json!([{ - "type": "function_call_output", - "call_id": "call_1", - "output": "done" - }])) - .expect("ordinary function state"); - - assert!(search.contains_tool_search_state()); - assert!(!ordinary.contains_tool_search_state()); - } - #[test] fn tool_search_replay_rejects_invalid_known_shapes() { for item in [ diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index 2b3cb0e4..395b40a4 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -210,42 +210,6 @@ impl TryFrom for FunctionToolCall { } } -impl TryFrom<&EventPayload> for ToolSearchCall { - type Error = ToolError; - - fn try_from(payload: &EventPayload) -> Result { - let EventPayload::OutputItemAdded { - item_id, - call_id, - execution, - status, - arguments, - .. - } = payload - else { - return Err(tool_search::invalid_upstream_search_call()); - }; - let call_id = call_id - .as_deref() - .filter(|call_id| !call_id.trim().is_empty()) - .ok_or_else(tool_search::invalid_upstream_search_call)?; - if item_id.trim().is_empty() { - return Err(tool_search::invalid_upstream_search_call()); - } - let execution = execution.ok_or_else(tool_search::invalid_upstream_search_call)?; - if status.as_deref() != Some("in_progress") || arguments.as_ref().is_none_or(|value| !value.is_empty()) { - return Err(tool_search::invalid_upstream_search_call()); - } - Ok(Self { - id: item_id.clone(), - call_id: call_id.to_owned(), - execution, - arguments: serde_json::Map::new(), - status: ToolSearchStatus::InProgress, - }) - } -} - /// A freeform custom tool invocation. /// /// `input` is opaque text and must not be parsed as function-call JSON. @@ -800,17 +764,6 @@ impl ApplyDone for FunctionToolCall { } } -impl ApplyDone for ToolSearchCall { - fn apply_done(&mut self, payload: &EventPayload, _buffer: &mut String) { - let EventPayload::OutputItemDone { item, .. } = payload else { - return; - }; - if let Some(call) = deserialize_from_value_opt::(item.clone()) { - *self = call; - } - } -} - impl ApplyDone for CustomToolCall { fn apply_done(&mut self, payload: &EventPayload, buffer: &mut String) { match payload { @@ -1227,9 +1180,6 @@ mod tests { name: None, namespace: None, call_id: None, - execution: None, - status: None, - arguments: None, }; let mut item = McpListTools::try_from(&added).unwrap(); assert_eq!(item.id, "mcpl_1"); diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 7d664b04..6a5fca11 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::{CodexNamespaceMember, ResponsesTool}; +use super::tools::ResponsesTool; use crate::tool::{CodexNamespaceHandler, CustomHandler, ToolError}; use crate::utils::common::serialize_to_string; @@ -106,37 +106,6 @@ where } impl RequestPayload { - /// Whether this request contains public tool-search history, an explicit - /// search declaration, or any declaration whose schema is deferred. - #[must_use] - pub fn contains_tool_search_state(&self) -> bool { - self.contains_tool_search_state_for_input(&self.input) - } - - #[must_use] - pub(crate) fn contains_tool_search_state_for_input(&self, input: &ResponsesInput) -> bool { - input.contains_tool_search_state() - || self - .tools - .as_deref() - .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) - } - - fn ensure_tool_search_ready(&self) -> Result<(), ToolError> { - if self.input.contains_tool_search_state() - || self - .tools - .as_deref() - .is_some_and(|tools| tools.iter().any(tool_has_deferred_definition)) - { - Err(ToolError::Config( - "tool_search requests require prepared request-scoped state before upstream conversion".to_owned(), - )) - } else { - Ok(()) - } - } - /// Construct an `UpstreamRequest` suitable for forwarding to vLLM. /// /// Codex `namespace` tools' members are first renamed to their flat, @@ -152,7 +121,6 @@ impl RequestPayload { /// member, or when a custom tool declares a format whose constrained /// decoding cannot be preserved upstream. pub fn to_upstream_request(&self, stream: bool) -> Result, ToolError> { - self.ensure_tool_search_ready()?; // The gateway currently executes tool calls serially. Accept the client's // preference for compatibility, but do not advertise parallel execution // to the upstream model. @@ -199,26 +167,6 @@ impl RequestPayload { } } -fn tool_activates_tool_search(tool: &ResponsesTool) -> bool { - matches!(tool, ResponsesTool::ToolSearch(_)) || tool_has_deferred_definition(tool) -} - -fn tool_has_deferred_definition(tool: &ResponsesTool) -> bool { - match tool { - ResponsesTool::Function(function) => function.defer_loading == Some(true), - ResponsesTool::Namespace(namespace) => namespace.tools.iter().any( - |member| matches!(member, CodexNamespaceMember::Function(function) if function.defer_loading == Some(true)), - ), - ResponsesTool::ToolSearch(_) - | ResponsesTool::Mcp(_) - | ResponsesTool::WebSearch(_) - | ResponsesTool::FileSearch(_) - | ResponsesTool::CodeInterpreter(_) - | ResponsesTool::Custom(_) - | ResponsesTool::Unknown => false, - } -} - /// Server-side context management configuration for a Responses request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextManagement { @@ -368,96 +316,6 @@ impl From for Vec { mod tests { use super::*; - fn tool_search_declaration() -> Value { - serde_json::json!({ - "type": "tool_search", - "execution": "client", - "description": "Find a tool", - "parameters": { - "type": "object", - "properties": {"query": {"type": "string"}} - } - }) - } - - fn tool_search_request(tools: Vec, input: Value, parallel_tool_calls: Option) -> RequestPayload { - let mut request = serde_json::json!({ - "model": "test", - "parallel_tool_calls": parallel_tool_calls - }); - request["input"] = input; - request["tools"] = Value::Array(tools); - serde_json::from_value(request).expect("request fixture should deserialize") - } - - #[test] - fn deferred_declarations_require_tool_search_state_preparation() { - for tool in [ - serde_json::json!({ - "type": "function", - "name": "deferred_function", - "defer_loading": true - }), - serde_json::json!({ - "type": "namespace", - "name": "deferred_namespace", - "tools": [{ - "type": "function", - "name": "deferred_member", - "defer_loading": true - }] - }), - ] { - let request = tool_search_request(vec![tool], serde_json::json!("hi"), Some(false)); - assert!(request.contains_tool_search_state()); - assert!(request.to_upstream_request(false).is_err()); - } - } - - #[test] - fn to_upstream_request_rejects_unprepared_tool_search_state() { - let request = tool_search_request( - vec![tool_search_declaration()], - serde_json::json!([ - { - "type": "tool_search_call", - "id": "tsc_1", - "call_id": "call_search_1", - "arguments": {"query": "weather"} - }, - { - "type": "tool_search_output", - "call_id": "call_search_1", - "tools": [] - } - ]), - Some(false), - ); - - assert!( - request.to_upstream_request(false).is_err(), - "unprepared tool-search state must not be silently dropped or lowered" - ); - } - - #[test] - fn reserved_tool_search_name_is_allowed_without_tool_search_state() { - let request = tool_search_request( - vec![serde_json::json!({"type": "function", "name": "tool_search"})], - serde_json::json!("hi"), - Some(true), - ); - - let upstream = serde_json::to_value( - request - .to_upstream_request(false) - .expect("reserved name applies only while tool search is active"), - ) - .expect("upstream request serializes"); - assert_eq!(upstream["tools"][0]["name"], "tool_search"); - assert_eq!(upstream["parallel_tool_calls"], false); - } - #[test] fn compact_request_accepts_codex_compatibility_fields() { let request: CompactRequest = serde_json::from_value(serde_json::json!({ diff --git a/crates/agentic-server-core/tests/event_normalizer_test.rs b/crates/agentic-server-core/tests/event_normalizer_test.rs index 7778178e..1304e69e 100644 --- a/crates/agentic-server-core/tests/event_normalizer_test.rs +++ b/crates/agentic-server-core/tests/event_normalizer_test.rs @@ -1,5 +1,4 @@ -use agentic_core::events::{EventPayload, SSEEventType, SSEItemType, normalize_sse_line}; -use agentic_core::types::tools::ToolSearchExecution; +use agentic_core::events::{EventPayload, SSEEventType, normalize_sse_line}; use serde::Deserialize; // --- Unit tests (per-event-type parsing) --- @@ -209,8 +208,6 @@ fn test_output_item_added_function_call() { name, namespace, call_id, - execution, - .. } = &frame.payload { assert_eq!(item_id, "fc_1"); @@ -219,7 +216,6 @@ fn test_output_item_added_function_call() { assert_eq!(name.as_deref(), Some("get_weather")); assert_eq!(namespace.as_deref(), Some("mcp__weather")); assert_eq!(call_id.as_deref(), Some("call_1")); - assert_eq!(*execution, None); } else { panic!("expected OutputItemAdded payload"); } @@ -708,26 +704,6 @@ fn test_call_id_from_output_item_added() { } } -#[test] -fn test_native_tool_search_call_added_is_typed() { - let line = r#"data: {"type":"response.output_item.added","item":{"id":"tsc_native","type":"tool_search_call","status":"in_progress","call_id":"call_search","execution":"client","arguments":{}},"output_index":2,"sequence_number":4}"#; - let frame = normalize_sse_line(line).unwrap(); - - assert!(matches!( - frame.payload, - EventPayload::OutputItemAdded { - ref item_id, - item_type: SSEItemType::ToolSearchCall, - output_index: 2, - call_id: Some(ref call_id), - execution: Some(ToolSearchExecution::Client), - status: Some(ref status), - arguments: Some(ref arguments), - .. - } if item_id == "tsc_native" && call_id == "call_search" && status == "in_progress" && arguments.is_empty() - )); -} - #[test] fn test_custom_tool_input_stream_events_are_typed() { let delta = normalize_sse_line( diff --git a/crates/agentic-server/src/handler/http/responses.rs b/crates/agentic-server/src/handler/http/responses.rs index f13ad902..f6e7213a 100644 --- a/crates/agentic-server/src/handler/http/responses.rs +++ b/crates/agentic-server/src/handler/http/responses.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use agentic_core::executor::{ExecuteRequest, compact_response as execute_compaction}; use agentic_core::proxy::{ProxyRequest, proxy_request}; +use agentic_core::tool::ToolRegistry; use agentic_core::types::request_response::{CompactRequest, RequestPayload}; use agentic_core::types::tools::ResponsesTool; @@ -53,12 +54,13 @@ pub async fn responses(State(state): State, req: Request) -> Response Err(e) => return e, }; + let has_tool_search_state = ToolRegistry::request_has_tool_search_state(&payload); let should_execute = payload.store || payload.previous_response_id.is_some() || payload.conversation_id.is_some() || payload.input.contains_compaction() || payload.input.has_compaction_trigger() - || payload.contains_tool_search_state() + || has_tool_search_state || payload .context_management .as_ref() @@ -72,7 +74,7 @@ pub async fn responses(State(state): State, req: Request) -> Response has_conversation_id = payload.conversation_id.is_some(), has_compaction = payload.input.contains_compaction(), has_compaction_trigger = payload.input.has_compaction_trigger(), - has_tool_search_state = payload.contains_tool_search_state(), + has_tool_search_state, context_management = payload.context_management.as_ref().map_or(0, Vec::len), tools = payload.tools.as_ref().map_or(0, Vec::len), "routing HTTP responses request" diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index 25d1cc1d..d76ce8d7 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -51,7 +51,7 @@ fn upgrade_responses_ws( .max_frame_size(MAX_BODY_SIZE) .on_upgrade(move |socket| async move { let _websocket_guard = websocket_guard; - Box::pin(responses_ws_loop(socket, state, headers, principal)).await; + responses_ws_loop(socket, state, headers, principal).await; }) } From ef79009b12b7447578e3f223d9ed4adff0d50802 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Fri, 28 Aug 2026 07:52:41 +0000 Subject: [PATCH 09/11] Updates Signed-off-by: haoshan98 --- .../src/executor/rehydrate.rs | 2 +- crates/agentic-server-core/src/tool/mod.rs | 5 +- .../agentic-server-core/src/tool/registry.rs | 62 +- .../src/tool/tool_search.rs | 24 +- .../tool/tool_search_state_tests.rs} | 74 +- .../tool_search_characterization_test.rs | 208 ++++-- .../tests/tool_search_test.rs | 188 ----- round-trip.md | 642 ++++++++++++++++++ 8 files changed, 887 insertions(+), 318 deletions(-) rename crates/agentic-server-core/{tests/tool_search_state_test.rs => src/tool/tool_search_state_tests.rs} (94%) create mode 100644 round-trip.md diff --git a/crates/agentic-server-core/src/executor/rehydrate.rs b/crates/agentic-server-core/src/executor/rehydrate.rs index 0c1472d6..25f2e896 100644 --- a/crates/agentic-server-core/src/executor/rehydrate.rs +++ b/crates/agentic-server-core/src/executor/rehydrate.rs @@ -248,7 +248,7 @@ mod tests { assert!( registry .tool_search_state() - .is_some_and(crate::tool::ToolSearchState::is_active) + .is_some_and(crate::tool::tool_search::ToolSearchState::is_active) ); let upstream = ctx .enriched_request diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index 703ff326..13229c23 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -14,6 +14,9 @@ pub mod registry; pub mod tool_search; pub mod web_search; +#[cfg(test)] +mod tool_search_state_tests; + pub use codex::{CodexNamespaceHandler, NamespaceMap, model_visible_namespace_member_name}; pub use custom::CustomHandler; pub use executors::{GatewayExecutorRegistration, GatewayExecutors}; @@ -21,5 +24,5 @@ pub use function::FunctionHandler; pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput}; pub use mcp::{McpClient, McpClientPool, McpDiscoveredHandler, McpError, McpHandler, McpOperation, McpServerEntry}; pub use registry::{GatewayDispatchResult, ToolEntry, ToolRegistry, ToolType}; -pub use tool_search::{ToolSearchHandler, ToolSearchState}; +pub use tool_search::ToolSearchHandler; pub use web_search::WebSearchHandler; diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 1aa7e9ce..9ac095e9 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -12,11 +12,11 @@ use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; use super::tool_search::{ - TOOL_SEARCH_NAME, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, + TOOL_SEARCH_NAME, ToolSearchState, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, validate_blocking_response, }; use super::web_search::insert_web_search_entry; -use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; +use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput}; use crate::events::WireEvent; use crate::types::event::{MessageStatus, ResponseStatus}; @@ -319,7 +319,7 @@ impl ToolRegistry { ) -> Result { let state = super::ToolSearchHandler::prepare_request(request, restored_loaded_tools, restore_only_declared)?; let mut registry = Self::default(); - registry.install_tool_search_state(state.map(Box::new), false)?; + registry.install_tool_search_state(state.map(Box::new)); Ok(registry) } @@ -334,22 +334,12 @@ impl ToolRegistry { Some(tools) => Self::build_with_handlers(tools, executors).await?, None => Self::default(), }; - registry.install_tool_search_state(self.tool_search.take(), true)?; + registry.install_tool_search_state(self.tool_search.take()); Ok(registry) } - fn install_tool_search_state( - &mut self, - state: Option>, - validate_private_routes: bool, - ) -> Result<(), ToolError> { - if let Some(state) = state { - if validate_private_routes { - self.validate_tool_search_state(&state)?; - } - self.tool_search = Some(state); - } - Ok(()) + fn install_tool_search_state(&mut self, state: Option>) { + self.tool_search = state; } /// Public declarations to expose in response metadata. `Some([])` is @@ -530,34 +520,6 @@ impl ToolRegistry { &self.mcp_list_tools_items } - /// Validate the private dispatch table against prepared tool-search state. - fn validate_tool_search_state(&self, state: &ToolSearchState) -> Result<(), ToolError> { - if !state.is_active() { - return Ok(()); - } - if self - .entries - .keys() - .any(|name| state.withheld_function_names().contains(name)) - { - return Err(ToolError::Config( - "a loaded tool collides with a withheld function name".to_owned(), - )); - } - let Some(_) = state.synthetic_tool_search() else { - return Ok(()); - }; - let entry = self.entries.get(TOOL_SEARCH_NAME).ok_or_else(|| { - ToolError::Config("prepared tool-search declaration is missing from the private registry".to_owned()) - })?; - if entry.tool_type != ToolType::ToolSearch || entry.tool_type.is_gateway_owned() || entry.handler.is_some() { - return Err(ToolError::Config( - "prepared tool-search declaration has invalid private registry ownership".to_owned(), - )); - } - Ok(()) - } - pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) { CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref()); } @@ -695,9 +657,7 @@ mod tests { .await .expect("typed tool-search declaration builds normally"); - registry - .install_tool_search_state(Some(Box::new(state)), true) - .expect("prepared tool-search entry is already classified"); + registry.install_tool_search_state(Some(Box::new(state))); let entry = registry.lookup("tool_search").expect("tool-search entry"); assert_eq!(entry.tool_type, ToolType::ToolSearch); assert!(!entry.tool_type.is_gateway_owned()); @@ -740,9 +700,7 @@ mod tests { .await .expect("loaded function registry"); assert!(registry.lookup("tool_search").is_none()); - registry - .install_tool_search_state(Some(Box::new(state)), true) - .expect("enable replay translation"); + registry.install_tool_search_state(Some(Box::new(state))); let valid: FunctionToolCall = serde_json::from_value(serde_json::json!({ "type": "function_call", "id": "fc_search", "call_id": "call_search", @@ -804,9 +762,7 @@ mod tests { let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .expect("private registry"); - registry - .install_tool_search_state(Some(Box::new(state)), true) - .expect("state application"); + registry.install_tool_search_state(Some(Box::new(state))); assert!( !registry diff --git a/crates/agentic-server-core/src/tool/tool_search.rs b/crates/agentic-server-core/src/tool/tool_search.rs index 32775fe3..4b649795 100644 --- a/crates/agentic-server-core/src/tool/tool_search.rs +++ b/crates/agentic-server-core/src/tool/tool_search.rs @@ -270,7 +270,7 @@ impl CatalogEntry { /// /// The state deliberately has no `Serialize` implementation and its `Debug` /// output contains counts only. -pub struct ToolSearchState { +pub(crate) struct ToolSearchState { activity: ToolSearchActivity, has_completed_search: bool, public_effective_tools: Option>, @@ -334,7 +334,8 @@ impl ToolSearchState { /// Returns [`ToolError::Config`] for an invalid public declaration, /// call/output ordering or linkage error, duplicate/conflicting definition, /// or normalized-name collision. - pub fn build(request: &RequestPayload) -> Result { + #[cfg(test)] + pub(crate) fn build(request: &RequestPayload) -> Result { Self::build_with_loaded_tools(request, &[], false) } @@ -346,8 +347,9 @@ impl ToolSearchState { /// /// # Errors /// - /// Returns [`ToolError::Config`] under the same conditions as [`Self::build`]. - pub fn build_with_loaded_tools( + /// Returns [`ToolError::Config`] for invalid declarations, history, + /// definitions, or normalized-name collisions. + pub(crate) fn build_with_loaded_tools( request: &RequestPayload, restored_loaded_tools: &[ResponsesTool], restore_only_declared: bool, @@ -443,12 +445,13 @@ impl ToolSearchState { } #[must_use] - pub const fn is_active(&self) -> bool { + pub(crate) const fn is_active(&self) -> bool { matches!(self.activity, ToolSearchActivity::Active) } #[must_use] - pub fn public_effective_tools(&self) -> Option<&[ResponsesTool]> { + #[cfg(test)] + pub(crate) fn public_effective_tools(&self) -> Option<&[ResponsesTool]> { self.public_effective_tools.as_deref() } @@ -470,13 +473,15 @@ impl ToolSearchState { /// This remains separate from `public_effective_tools`: an initially /// deferred definition stays deferred publicly even after becoming loaded. #[must_use] - pub fn loaded_public_tools(&self) -> &[ResponsesTool] { + #[cfg(test)] + pub(crate) fn loaded_public_tools(&self) -> &[ResponsesTool] { &self.loaded_public_tools } /// Private tool-search declaration used by request-scoped registry and upstream normalization. #[must_use] - pub const fn synthetic_tool_search(&self) -> Option<&ToolSearchToolParam> { + #[cfg(test)] + pub(crate) const fn synthetic_tool_search(&self) -> Option<&ToolSearchToolParam> { self.synthetic_tool_search.as_ref() } @@ -493,7 +498,7 @@ impl ToolSearchState { /// /// Returns [`ToolError::Config`] when the effective tool choice conflicts /// with the prepared private tool set. - pub fn prepare_inference_request(&mut self, request: &mut RequestPayload) -> Result<(), ToolError> { + pub(crate) fn prepare_inference_request(&mut self, request: &mut RequestPayload) -> Result<(), ToolError> { validate_effective_tool_choice(request.tool_choice.as_ref(), &self.withheld_function_names)?; let input = self.private_upstream_input.take().ok_or_else(|| { ToolError::Config("tool-search private inference input has already been consumed".to_owned()) @@ -553,7 +558,6 @@ fn request_contains_tool_search_state(request: &RequestPayload, input: &Response .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) } - pub(crate) fn request_has_tool_search_state(request: &RequestPayload) -> bool { request_contains_tool_search_state(request, &request.input) } diff --git a/crates/agentic-server-core/tests/tool_search_state_test.rs b/crates/agentic-server-core/src/tool/tool_search_state_tests.rs similarity index 94% rename from crates/agentic-server-core/tests/tool_search_state_test.rs rename to crates/agentic-server-core/src/tool/tool_search_state_tests.rs index 8bc16be4..d5d5f90a 100644 --- a/crates/agentic-server-core/tests/tool_search_state_test.rs +++ b/crates/agentic-server-core/src/tool/tool_search_state_tests.rs @@ -1,6 +1,9 @@ -use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; -use agentic_core::{InputItem, RequestPayload, ResponsesInput}; +use super::model_visible_namespace_member_name; +use super::tool_search::ToolSearchState; +use crate::{InputItem, RequestPayload, ResponsesInput}; use serde_json::{Value, json}; +use std::fs; +use std::path::Path; fn request(tools: Value, input: Value) -> RequestPayload { let mut value = json!({ @@ -61,7 +64,7 @@ fn search_output(id: &str, tools: Vec) -> Value { value } -fn tool_values(tools: Option<&[agentic_core::ResponsesTool]>) -> Value { +fn tool_values(tools: Option<&[crate::ResponsesTool]>) -> Value { serde_json::to_value(tools).expect("prepared tools serialize") } @@ -89,6 +92,57 @@ fn synthetic_description(state: &ToolSearchState) -> &str { .expect("active tool search has a synthetic description") } +fn fixture_json(filename: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/cassettes/tool_search") + .join(filename); + serde_json::from_str(&fs::read_to_string(path).expect("tool-search fixture should be readable")) + .expect("tool-search fixture should be valid JSON") +} + +fn lowered_fixture_tools(tools: Value, input: Value) -> Value { + let public = request(tools, input); + let mut state = ToolSearchState::build(&public).expect("fixture should build tool-search state"); + let private = private_request(&mut state, &public); + let upstream = private + .to_upstream_request(false) + .expect("fixture should lower into an upstream request"); + serde_json::to_value(upstream).expect("upstream fixture should serialize")["tools"].clone() +} + +#[test] +fn mixed_catalog_fixtures_match_private_tool_search_lowering() { + let public_tools = fixture_json("openai_tools.json"); + let returned_tools = fixture_json("returned_tools.json"); + assert_eq!( + lowered_fixture_tools(public_tools.clone(), json!("find weather and timezone tools")), + fixture_json("vllm_initial_tools.json") + ); + assert_eq!( + lowered_fixture_tools( + public_tools, + json!([ + { + "type": "tool_search_call", + "id": "tsc_fixture", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather and timezone"} + }, + { + "type": "tool_search_output", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "tools": returned_tools + } + ]), + ), + fixture_json("vllm_tools_after_search.json") + ); +} + #[test] fn fresh_and_sequential_state_has_distinct_deterministic_views() { let deferred = function("get_weather", "Get weather", "string", true); @@ -1180,7 +1234,7 @@ fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { "encrypted_content": "The weather tool was loaded earlier." }]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "get_weather", "description": "Get weather", @@ -1199,9 +1253,7 @@ fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { let loaded = private .iter() .find_map(|tool| match tool { - agentic_core::types::tools::ResponsesTool::Function(function) - if function.name.as_str() == "get_weather" => - { + crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather" => { Some(function) } _ => None, @@ -1243,7 +1295,7 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { } ]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "get_weather", "description": "Get weather", @@ -1259,13 +1311,13 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { .public_effective_tools() .unwrap() .iter() - .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); + .all(|tool| !matches!(tool, crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); assert!(private_request(&mut state, &request) .tools .as_deref() .expect("private tools") .iter() - .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); + .all(|tool| !matches!(tool, crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); } #[test] @@ -1289,7 +1341,7 @@ fn replayed_loaded_marker_rejects_explicit_cross_kind_identity_collision() { "encrypted_content": "A function with this name was loaded earlier." }]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "shared_identity", "description": "Original function", diff --git a/crates/agentic-server-core/tests/tool_search_characterization_test.rs b/crates/agentic-server-core/tests/tool_search_characterization_test.rs index c7366de6..14f370e0 100644 --- a/crates/agentic-server-core/tests/tool_search_characterization_test.rs +++ b/crates/agentic-server-core/tests/tool_search_characterization_test.rs @@ -1,12 +1,12 @@ mod support; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use agentic_core::RequestPayload; -use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; +use agentic_core::tool::model_visible_namespace_member_name; use serde_json::Value; #[derive(Clone, Copy)] @@ -678,33 +678,10 @@ fn fixture_json(directory: &Path, filename: &str) -> Value { .unwrap_or_else(|error| panic!("{filename} should be valid JSON: {error}")) } -fn lowered_fixture_tools(tools: &Value, input: &Value) -> Value { - let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "fixture-model", - "input": input, - "tools": tools, - "store": false, - "stream": false, - "parallel_tool_calls": false - })) - .expect("fixture should deserialize as a public request"); - let mut state = ToolSearchState::build(&request).expect("fixture should build tool-search state"); - state - .prepare_inference_request(&mut request) - .expect("fixture should prepare private inference state"); - let upstream = request - .to_upstream_request(false) - .expect("fixture should lower into an upstream request"); - serde_json::to_value(upstream).expect("upstream fixture should serialize")["tools"].clone() -} - #[test] -fn mixed_catalog_fixtures_match_private_tool_search_lowering() { +fn mixed_catalog_tool_choice_fixtures_match_public_types() { let directory = tool_search_cassette_directory(); let public_tools = fixture_json(&directory, "openai_tools.json"); - let returned_tools = fixture_json(&directory, "returned_tools.json"); - let expected_initial = fixture_json(&directory, "vllm_initial_tools.json"); - let expected_loaded = fixture_json(&directory, "vllm_tools_after_search.json"); let openai_tool_choices = fixture_json(&directory, "openai_tool_choice_sequence.json"); let gateway_tool_choices = fixture_json(&directory, "gateway_tool_choice_sequence.json"); @@ -722,34 +699,6 @@ fn mixed_catalog_fixtures_match_private_tool_search_lowering() { })) .expect("every public tool-choice fixture should match the typed request model"); } - - assert_eq!( - lowered_fixture_tools(&public_tools, &serde_json::json!("find weather and timezone tools")), - expected_initial - ); - assert_eq!( - lowered_fixture_tools( - &public_tools, - &serde_json::json!([ - { - "type": "tool_search_call", - "id": "tsc_fixture", - "call_id": "call_fixture", - "execution": "client", - "status": "completed", - "arguments": {"query": "weather and timezone"} - }, - { - "type": "tool_search_output", - "call_id": "call_fixture", - "execution": "client", - "status": "completed", - "tools": returned_tools - } - ]) - ), - expected_loaded - ); } fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { @@ -1469,6 +1418,80 @@ const PROVIDER_PARITY_CASSETTES: [&str; 7] = [ GATEWAY_WEBSOCKET_CASSETTE, ]; +fn visit_recorded_values(value: &Value, visitor: &mut impl FnMut(&serde_json::Map)) { + match value { + Value::Object(object) => { + visitor(object); + for child in object.values() { + visit_recorded_values(child, visitor); + } + } + Value::Array(array) => { + for child in array { + visit_recorded_values(child, visitor); + } + } + Value::String(text) => { + if matches!(text.trim().as_bytes().first(), Some(b'{' | b'[')) + && let Ok(decoded) = serde_json::from_str::(text) + { + visit_recorded_values(&decoded, visitor); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn validate_gateway_cassette_has_no_private_search_projection(raw: &Value) -> Result<(), String> { + let mut public_search_call_ids = HashSet::new(); + let mut search_item_ids = HashSet::new(); + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + if item_type == Some("tool_search_call") { + if let Some(call_id) = object.get("call_id").and_then(Value::as_str) { + public_search_call_ids.insert(call_id.to_owned()); + } + if let Some(item_id) = object.get("id").and_then(Value::as_str) { + search_item_ids.insert(item_id.to_owned()); + } + } else if item_type == Some("function_call") + && object.get("name").and_then(Value::as_str) == Some("tool_search") + && let Some(item_id) = object.get("id").and_then(Value::as_str) + { + search_item_ids.insert(item_id.to_owned()); + } + }); + + let mut violation = None; + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + let name = object.get("name").and_then(Value::as_str); + if matches!(item_type, Some("function" | "function_call")) && name == Some("tool_search") { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked the private synthetic search projection: {object:?}") + }); + } else if item_type == Some("function_call_output") { + let call_id = object.get("call_id").and_then(Value::as_str); + if call_id.is_some_and(|call_id| public_search_call_ids.contains(call_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked a normalized search function output: {object:?}") + }); + } + } else if matches!( + item_type, + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) { + let item_id = object.get("item_id").and_then(Value::as_str); + if item_id.is_some_and(|item_id| search_item_ids.contains(item_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked normalized search argument events: {object:?}") + }); + } + } + }); + violation.map_or(Ok(()), Err) +} + fn provider_projection(filename: &str) -> Projection { if filename.contains("openai-reference") || filename.contains("gateway") { Projection::Public @@ -1665,6 +1688,26 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow let raw_document = serde_yaml::from_str::(&raw_text).expect("characterization YAML should be valid"); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); assert_eq!(cassette.turns.len(), 4, "{filename} should contain four turns"); + if filename.contains("gateway") { + let raw_turns = raw_document["turns"] + .as_array() + .expect("raw gateway cassette should contain turns"); + let mut decoded_surfaces = Vec::new(); + for (raw_turn, turn) in raw_turns.iter().zip(&cassette.turns) { + decoded_surfaces.push(raw_turn["request"]["body"].clone()); + if let Some(body) = &turn.response.body { + decoded_surfaces.push(body.clone()); + } + if turn.response.sse.is_some() { + decoded_surfaces.extend(support::recorded_named_sse_events(turn)); + } + if let Some(websocket) = raw_turn["response"]["websocket"].as_array() { + decoded_surfaces.extend(websocket.iter().cloned()); + } + } + validate_gateway_cassette_has_no_private_search_projection(&Value::Array(decoded_surfaces)) + .unwrap_or_else(|error| panic!("{filename}: {error}")); + } let responses = cassette.turns.iter().map(terminal_response).collect::>(); let inputs = cassette.turns[1..] .iter() @@ -1678,6 +1721,32 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow #[test] fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { let directory = tool_search_cassette_directory(); + let expected_names = PROVIDER_PARITY_CASSETTES + .iter() + .map(|filename| (*filename).to_owned()) + .collect::>(); + let actual_names = fs::read_dir(&directory) + .expect("tool-search cassette directory") + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { + return None; + } + let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); + (cassette.turns.len() == 4).then(|| { + path.file_name() + .and_then(|filename| filename.to_str()) + .expect("cassette filename should be UTF-8") + .to_owned() + }) + }) + .collect::>(); + assert_eq!( + actual_names, expected_names, + "the final seven-cassette flow matrix must be exact" + ); + let expected_tools = serde_json::from_str::( &fs::read_to_string(directory.join("returned_tools.json")).expect("returned tool fixture should be readable"), ) @@ -1710,3 +1779,34 @@ fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { } } } + +#[test] +fn provider_parity_non_leak_detector_rejects_nested_private_shapes() { + let cases = [ + serde_json::json!({"response": {"tools": [{"type": "function", "name": "tool_search"}]}}), + serde_json::json!({"response": {"output": [{ + "type": "function_call", "id": "fc_private", "call_id": "call_search", "name": "tool_search" + }]}}), + serde_json::json!({ + "request": {"input": [{ + "type": "tool_search_call", "id": "tsc_public", "call_id": "call_search" + }, { + "type": "function_call_output", "call_id": "call_search", "output": "{}" + }]} + }), + serde_json::json!([{ + "type": "response.output_item.added", + "item": {"type": "tool_search_call", "id": "tsc_public", "call_id": "call_search"} + }, { + "type": "response.function_call_arguments.delta", "item_id": "tsc_public", "delta": "{}" + }]), + serde_json::json!([r#"{"type":"function_call","id":"fc_ws","call_id":"call_ws","name":"tool_search"}"#]), + ]; + + for case in cases { + assert!( + validate_gateway_cassette_has_no_private_search_projection(&case).is_err(), + "private nested shape should be rejected: {case}" + ); + } +} diff --git a/crates/agentic-server-core/tests/tool_search_test.rs b/crates/agentic-server-core/tests/tool_search_test.rs index 2bb5e0cd..1aa829bc 100644 --- a/crates/agentic-server-core/tests/tool_search_test.rs +++ b/crates/agentic-server-core/tests/tool_search_test.rs @@ -1,11 +1,6 @@ -mod support; - -use std::collections::HashSet; use std::collections::VecDeque; use std::fmt::Write as _; -use std::fs; use std::future::Future; -use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1167,186 +1162,3 @@ async fn dynamic_namespace_forward_references_fail_before_inference() { } assert!(requests.lock().await.is_empty(), "inference must not run"); } - -fn visit_recorded_values(value: &Value, visitor: &mut impl FnMut(&serde_json::Map)) { - match value { - Value::Object(object) => { - visitor(object); - for child in object.values() { - visit_recorded_values(child, visitor); - } - } - Value::Array(array) => { - for child in array { - visit_recorded_values(child, visitor); - } - } - Value::String(text) => { - if matches!(text.trim().as_bytes().first(), Some(b'{' | b'[')) - && let Ok(decoded) = serde_json::from_str::(text) - { - visit_recorded_values(&decoded, visitor); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) => {} - } -} - -fn validate_gateway_cassette_has_no_private_search_projection(raw: &Value) -> Result<(), String> { - let mut public_search_call_ids = HashSet::new(); - let mut search_item_ids = HashSet::new(); - visit_recorded_values(raw, &mut |object| { - let item_type = object.get("type").and_then(Value::as_str); - if item_type == Some("tool_search_call") { - if let Some(call_id) = object.get("call_id").and_then(Value::as_str) { - public_search_call_ids.insert(call_id.to_owned()); - } - if let Some(item_id) = object.get("id").and_then(Value::as_str) { - search_item_ids.insert(item_id.to_owned()); - } - } else if item_type == Some("function_call") - && object.get("name").and_then(Value::as_str) == Some("tool_search") - && let Some(item_id) = object.get("id").and_then(Value::as_str) - { - search_item_ids.insert(item_id.to_owned()); - } - }); - - let mut violation = None; - visit_recorded_values(raw, &mut |object| { - let item_type = object.get("type").and_then(Value::as_str); - let name = object.get("name").and_then(Value::as_str); - if matches!(item_type, Some("function" | "function_call")) && name == Some("tool_search") { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked the private synthetic search projection: {object:?}") - }); - } else if item_type == Some("function_call_output") { - let call_id = object.get("call_id").and_then(Value::as_str); - if call_id.is_some_and(|call_id| public_search_call_ids.contains(call_id)) { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked a normalized search function output: {object:?}") - }); - } - } else if matches!( - item_type, - Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") - ) { - let item_id = object.get("item_id").and_then(Value::as_str); - if item_id.is_some_and(|item_id| search_item_ids.contains(item_id)) { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked normalized search argument events: {object:?}") - }); - } - } - }); - violation.map_or(Ok(()), Err) -} - -#[test] -fn provider_parity_matrix_is_exact_and_gateway_has_no_private_search_leaks() { - const FLOW_CASSETTES: [(&str, bool); 7] = [ - ("tool-search-openai-reference-gpt-5.6-nonstreaming.yaml", false), - ("tool-search-openai-reference-gpt-5.6-streaming.yaml", false), - ( - "tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", - false, - ), - ("tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", false), - ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", true), - ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", true), - ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml", true), - ]; - - let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); - let expected_names = FLOW_CASSETTES - .iter() - .map(|(filename, _)| (*filename).to_owned()) - .collect::>(); - let actual_names = fs::read_dir(&directory) - .expect("tool-search cassette directory") - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { - return None; - } - let cassette = support::load_cassette(path.to_str().expect("cassette path")); - (cassette.turns.len() == 4).then(|| { - path.file_name() - .and_then(|filename| filename.to_str()) - .expect("cassette filename") - .to_owned() - }) - }) - .collect::>(); - assert_eq!( - actual_names, expected_names, - "the final seven-cassette flow matrix must be exact" - ); - - for (filename, gateway) in FLOW_CASSETTES { - let path = directory.join(filename); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - assert_eq!( - fs::metadata(&path).expect("cassette metadata").permissions().mode() & 0o111, - 0, - "checked-in cassette must not be executable: {filename}" - ); - } - if gateway { - let raw = - serde_yaml::from_str::(&fs::read_to_string(&path).expect("gateway cassette should be readable")) - .expect("gateway cassette YAML"); - let raw_turns = raw["turns"].as_array().expect("raw gateway cassette turns"); - let cassette = support::load_cassette(path.to_str().expect("gateway cassette path")); - let mut decoded_surfaces = Vec::new(); - for (raw_turn, turn) in raw_turns.iter().zip(&cassette.turns) { - decoded_surfaces.push(raw_turn["request"]["body"].clone()); - if let Some(body) = &turn.response.body { - decoded_surfaces.push(body.clone()); - } - if turn.response.sse.is_some() { - decoded_surfaces.extend(support::recorded_named_sse_events(turn)); - } - if let Some(websocket) = raw_turn["response"]["websocket"].as_array() { - decoded_surfaces.extend(websocket.iter().cloned()); - } - } - validate_gateway_cassette_has_no_private_search_projection(&Value::Array(decoded_surfaces)) - .unwrap_or_else(|error| panic!("{error}")); - } - } -} - -#[test] -fn provider_parity_non_leak_detector_rejects_nested_private_shapes() { - let cases = [ - json!({"response": {"tools": [{"type": "function", "name": "tool_search"}]}}), - json!({"response": {"output": [{ - "type": "function_call", "id": "fc_private", "call_id": "call_search", "name": "tool_search" - }]}}), - json!({ - "request": {"input": [{ - "type": "tool_search_call", "id": "tsc_public", "call_id": "call_search" - }, { - "type": "function_call_output", "call_id": "call_search", "output": "{}" - }]} - }), - json!([{ - "type": "response.output_item.added", - "item": {"type": "tool_search_call", "id": "tsc_public", "call_id": "call_search"} - }, { - "type": "response.function_call_arguments.delta", "item_id": "tsc_public", "delta": "{}" - }]), - json!([r#"{"type":"function_call","id":"fc_ws","call_id":"call_ws","name":"tool_search"}"#]), - ]; - - for case in cases { - assert!( - validate_gateway_cassette_has_no_private_search_projection(&case).is_err(), - "private nested shape should be rejected: {case}" - ); - } -} diff --git a/round-trip.md b/round-trip.md new file mode 100644 index 00000000..69f2b27f --- /dev/null +++ b/round-trip.md @@ -0,0 +1,642 @@ +# `POST /v1/responses`: repository entrypoints and full round trip + +This document is a Python-friendly map of the Rust repository and the complete logical path of a `POST /v1/responses` +request. It follows the local executor path, including rehydration, tool normalization, upstream inference, the built-in +tool loop, persistence, and the response returned to the client. It also shows the shorter pass-through path. + +The short answer is that the HTTP gateway starts in +[`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307), while the main Responses orchestration +starts in [`ExecuteRequest::run`](crates/agentic-server-core/src/executor/engine.rs#L555). + +## 1. Rust mental model for a Python developer + +Rust/Cargo separates a repository into a few concepts that Python often leaves implicit: + +| Rust concept | Rough Python analogy | Meaning here | +| --- | --- | --- | +| Cargo workspace | monorepo | The root [`Cargo.toml`](Cargo.toml#L1) groups packages under `crates/`. | +| Package | installable Python distribution | A directory with a `Cargo.toml`, such as `crates/agentic-server`. | +| Crate | one compiled library or executable | A package can produce a library crate and multiple binary crates. | +| Target | one build output | A library, binary, test, example, or benchmark produced by Cargo. | +| `src/main.rs` | `if __name__ == "__main__":` | Default executable entrypoint for a package. | +| `src/bin/name.rs` | another console-script entrypoint | An additional executable named `name`. | +| `src/lib.rs` | public package/module interface | Exports code shared by binaries and other crates. | +| `mod server;` | load a local module | Makes `server.rs` part of the crate's module tree. | +| `use foo::Bar` | `from foo import Bar` | Brings a name into scope. | +| `pub` | exported/public | Makes an item visible outside its module or crate. | +| `#[tokio::main]` | create an async event loop, then run `main()` | Starts the Tokio runtime so `main` can use async I/O. | +| `Result` and `?` | return a value or raise/propagate an exception | `?` returns early when the result is an error. | +| `.await` | `await` | Suspends the current task while I/O proceeds; it does not block the Tokio worker thread. | +| `Arc` | shared, reference-counted application state | `Arc::clone` cheaply clones a pointer, not the underlying database/client state. | + +The key difference is that one Cargo package can have more than one executable. Finding one `main.rs` does not imply +that it is the repository's only entrypoint. + +## 2. Workspace, crates, and executable entrypoints + +The root workspace is declared in [`Cargo.toml`](Cargo.toml#L1) and contains three packages: + +| Package | Role | +| --- | --- | +| `agentic-server` | Axum HTTP/WebSocket transport, configuration, server lifecycle, and two executables. | +| `agentic-server-core` (`agentic_core` in Rust imports) | Framework-independent types, rehydration, inference, tools, tool loop, and persistence. | +| `agentic-praxis` | Placeholder for a future Praxis integration. | + +The dependency direction is intentional: the transport crate calls the core crate; core does not depend on Axum. + +`agentic-server` produces two executables: + +| Executable | Entrypoint | Purpose | +| --- | --- | --- | +| `agentic-server` | [`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307) | The HTTP/WebSocket gateway. Cargo infers this default binary from `src/main.rs`. | +| `agentic` | [`crates/agentic-server/src/bin/agentic.rs`](crates/agentic-server/src/bin/agentic.rs#L11) | User-facing launcher for the gateway and coding harnesses. It is declared explicitly in [`crates/agentic-server/Cargo.toml`](crates/agentic-server/Cargo.toml#L52). | + +For API request handling, follow `agentic-server`; the `agentic` launcher is not in the request path. + +## 3. Gateway startup chain + +The gateway process starts at `#[tokio::main] async fn main()`: + +```text +crates/agentic-server/src/main.rs::main + ├─ initialize tracing + ├─ parse CLI flags/environment/config.toml + ├─ construct agentic_core::config::Config + └─ server::run(...) or server::run_with_llm(...) + ├─ optionally discover OIDC configuration + ├─ wait for the upstream LLM to become ready + ├─ build_state(...) + │ ├─ ProxyState::new(...) + │ └─ ExecutionContext::from_config(...) + │ ├─ response/conversation storage handlers + │ ├─ shared reqwest HTTP client + │ ├─ built-in tool executors + │ └─ upstream LLM base URL + ├─ build_router_with_auth(...) + ├─ TcpListener::bind(...) + └─ axum::serve(...) +``` + +The important source points are: + +- Process entry and configuration: [`main.rs`](crates/agentic-server/src/main.rs#L307) +- State construction: [`server.rs::build_state`](crates/agentic-server/src/server.rs#L35) +- Router, listener, and server: [`server.rs::serve_gateway`](crates/agentic-server/src/server.rs#L52) +- Shared executor dependencies: [`ExecutionContext`](crates/agentic-server-core/src/executor/request.rs#L52) + +`ExecutionContext::responses_url()` appends `/v1/responses` to the configured LLM base URL. For example, +`http://127.0.0.1:8000` becomes `http://127.0.0.1:8000/v1/responses`. + +## 4. API and route surface + +Routes are wired in +[`build_router_with_auth`](crates/agentic-server/src/app.rs#L242): + +| Method and path | Purpose | +| --- | --- | +| `POST /v1/responses` | OpenAI-compatible Responses API over JSON or server-sent events (SSE). | +| `GET /v1/responses` | Responses API WebSocket upgrade. It reaches the same executor from a long-lived session loop. | +| `POST /v1/responses/compact` | Explicit context compaction. | +| `POST /v1/conversations` | Create a durable conversation. | +| `POST /v1/messages` | Anthropic-compatible Messages API. | +| `POST /v1/messages/count_tokens` | Anthropic-compatible token counting. | +| `GET /v1/models` | Upstream model listing with gateway handling as configured. | +| `GET /health` | Process liveness. | +| `GET /ready` | Storage and upstream readiness. | + +`/health` and `/ready` are public. The remaining routes are protected when OIDC authentication is configured. + +## 5. The first fork: pass through or execute locally + +The HTTP handler is +[`responses`](crates/agentic-server/src/handler/http/responses.rs#L49). It first retains two representations of the +same body: + +- `bytes`: the original raw request body, used when passing the request through unchanged. +- `payload`: a deserialized, typed [`RequestPayload`](crates/agentic-server-core/src/types/request_response.rs#L14), + used when the gateway must inspect or modify the request. + +The fork is exactly: + +```rust +if should_execute { + execute_responses(&state, parts, payload).await +} else { + proxy_responses(&state, parts, bytes).await +} +``` + +`should_execute` is true when any of these apply: + +- `store` is true; +- `previous_response_id` is present; +- `conversation_id` is present; +- the input contains a compaction item or compaction trigger; +- non-empty `context_management` is present; or +- the request contains any tool declaration other than a plain `ResponsesTool::Function`. + +That last test is structural: `Custom`, `Namespace`, and `Unknown` also select the executor path, even though they are +not all built-in tools and are not all locally executable. Selecting the executor does not by itself mean the gateway +will execute every declared tool. + +`RequestPayload.store` defaults to `true`, so a simple request normally uses the local executor unless it explicitly +sends `"store": false`. A request with `store: false` can still use the executor when another condition above requires +local behavior. + +The two branches are: + +```text +responses() +├─ should_execute == false +│ └─ proxy_responses() +│ └─ proxy_request() +│ └─ pass request through to upstream; no local executor or persistence +│ +└─ should_execute == true + └─ execute_responses() + └─ ExecuteRequest::new(payload, Arc::clone(&state.exec_ctx)) + └─ .with_auth(auth) + └─ .run().await +``` + +The exact link to `ExecuteRequest::run()` is in +[`execute_responses`](crates/agentic-server/src/handler/http/responses.rs#L29). Its result has two successful shapes: + +- `Either::Left(ResponsePayload)`: a complete non-streaming JSON response. +- `Either::Right(BoxStream)`: a stream of complete SSE frames. + +### Inbound identity versus upstream LLM credentials + +There are two different authentication concerns: + +1. **Inbound OIDC identity authentication** decides whether the caller may use the gateway. +2. **Upstream LLM authentication** supplies the credential used when the gateway calls vLLM or another inference + service. + +When OIDC is enabled for the OpenAI-compatible routes, the +[`require_oidc`](crates/agentic-server/src/auth.rs#L351) middleware verifies the caller's bearer token, stores the +authenticated principal in request extensions, and removes the inbound `Authorization` and OpenAI `x-api-key` headers. +They therefore cannot be mistaken for an upstream LLM credential. The handler then falls back to the configured +`OPENAI_API_KEY`; if that key is configured and non-empty, it is used for the upstream call. Otherwise the executor +sends no upstream bearer credential. + +Without OIDC, the executor path's `extract_bearer` uses a caller bearer token when supplied and otherwise falls back to +the configured `OPENAI_API_KEY`. On the pass-through path, eligible caller credential headers are preserved; the proxy +injects the configured key only when the client supplied neither `Authorization` nor `x-api-key`. + +## 6. `ExecuteRequest::run()` and rehydration + +[`ExecuteRequest::run`](crates/agentic-server-core/src/executor/engine.rs#L555) begins with: + +```rust +let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?; +``` + +Yes: this is the item-history rehydration step. The project-specific meaning of **rehydration** is loading stored items, +restoring their order and effective request settings, and building the input for a continuation. + +[`rehydrate_conversation`](crates/agentic-server-core/src/executor/rehydrate.rs#L26) builds a `RequestContext` with: + +- `original_request`: an unchanged copy used for storage semantics and response metadata; +- `enriched_request`: the request that can be augmented before upstream inference; +- `new_input_items`: only the newly submitted input, retained for persistence; +- a generated response ID; +- optional conversation ID and version information. + +It then selects one path: + +```text +conversation_id present + → load a conversation snapshot and prepend its item history + +previous_response_id present + → rehydrate the stored-response chain + → restore effective tools/tool_choice when appropriate + → prepend its item history + +neither ID present + → use only the new input items +``` + +Supplying both IDs is rejected. After rehydration, `run()` chooses the client transport requested by +`original_request.stream`: + +```text +stream == false → run_blocking(...) → Either::Left(ResponsePayload) +stream == true → run_stream(...) → Either::Right(BoxStream) +``` + +Here “blocking” means **non-streaming API behavior**—wait for one complete upstream JSON body. It does not mean blocking +the Tokio worker thread; the network operations are asynchronous. + +Both modes then use +[`run_until_gateway_tools_complete`](crates/agentic-server-core/src/executor/engine.rs#L98), which has another important +branch: + +```text +input has compaction_trigger + → run_compaction_trigger(...) + → run one direct blocking summarization inference + → return a compaction response without entering the ordinary tool loop + +ordinary input + → run_gateway_tool_loop(...) +``` + +Inside the ordinary loop, `maybe_compact_context(...)` can also perform an automatic blocking compaction inference +before the main response inference for a round. Consequently, the ten-round limit described later caps ordinary +tool-loop/main-response rounds, not every upstream model invocation: automatic compaction can add a model call. + +## 7. Tool registry versus tool normalization + +These are separate operations with separate purposes. + +### 7.1 Request-scoped tool registry: how calls will be routed + +Before the inference rounds, [`run_gateway_tool_loop`](crates/agentic-server-core/src/executor/engine.rs#L119) calls: + +```rust +ToolRegistry::build_with_handlers(tools, &mut executors).await? +``` + +The [`ToolRegistry`](crates/agentic-server-core/src/tool/registry.rs#L159) maps each model-visible tool name to routing +metadata. It can: + +- flatten Codex namespace member names consistently; +- discover tools from Model Context Protocol (MCP) servers; +- associate names with their original tool type and configuration; +- attach an executor for a gateway-executed built-in tool; and +- retain mappings needed to restore the public output shape after inference. + +The registry answers: **“When the model emits this tool name, what is it and who executes it?”** It is request-scoped +runtime routing state, not part of the Responses wire format. + +### 7.2 Tool normalization: what shape the upstream accepts + +Normalization happens later, immediately before each upstream request, in +[`RequestPayload::to_upstream_request`](crates/agentic-server-core/src/types/request_response.rs#L123). Both +`fetch_blocking_payload` and `fetch_stream_payload` call it. + +It: + +1. resolves namespace members to flat, model-visible names; +2. validates the resolved declarations; +3. calls `ResponsesTool::to_function_tools` for every declaration, producing zero or more upstream function tools; +4. wraps each produced function tool as `UpstreamTool::Function`; +5. normalizes `tool_choice`; and +6. always sends `parallel_tool_calls: false`, asking the upstream not to generate parallel function calls. + +The per-tool conversion is in +[`ResponsesTool::to_function_tools`](crates/agentic-server-core/src/tool/normalize.rs#L90). A declaration can expand +to more than one function tool (notably MCP discovery), one function tool, or none. `FileSearch`, `CodeInterpreter`, +and `Unknown` are currently skipped during normalization; they produce no upstream tool declaration. For example, a +public web search declaration becomes one upstream function tool. The public meaning is preserved even though the +upstream wire shape changes. + +`parallel_tool_calls: false` does not mean gateway tool calls are all executed one at a time. If a round nevertheless +contains multiple gateway-executed built-in tool calls, the gateway executes them concurrently with an order-preserving +sliding window bounded at five calls. It then assembles results in model-output order before continuing the loop. + +The distinction is: + +```text +rehydration = Which prior items and effective settings belong in this request? +tool registry = How will a returned tool call be classified, restored, and executed? +tool normalization= What function-tool JSON shape must be sent to the upstream inference server? +``` + +## 8. What “payload” means + +**Payload** is ordinary networking terminology, not a special Rust feature. It means the meaningful data carried by a +request or response, separate from transport details such as HTTP headers and status codes. + +In this path: + +| Name | Meaning | +| --- | --- | +| `RequestPayload` | Typed Rust representation of the client's Responses JSON request body. | +| `UpstreamRequest` | Normalized request body sent to the upstream LLM endpoint. | +| `ResponsePayload` | Typed, complete Responses object for one upstream inference round (later combined across rounds). | +| `StreamPayload` | Internal wrapper containing the accumulated `ResponsePayload` plus deferred streaming events. It is not a separate public API response. | + +`ResponsePayload` includes the response ID, model, status, output items, token usage, continuation/conversation IDs, +and error or incomplete details. + +## 9. Fetching a blocking or streaming payload + +Both functions call the same upstream `/v1/responses` endpoint and ultimately produce a complete `ResponsePayload`. +They differ in how the upstream response is transported. + +### 9.1 `fetch_blocking_payload()` + +[`fetch_blocking_payload`](crates/agentic-server-core/src/executor/upstream.rs#L35) performs: + +```text +enriched RequestPayload + → to_upstream_request(false) # normalize; set stream=false + → serialize_to_string(...) + → fetch_response_json(...) + → POST upstream /v1/responses + → await the complete JSON body + → ResponseAccumulator::from_json(...) + → finalize one ResponsePayload +``` + +### 9.2 `fetch_stream_payload()` + +[`fetch_stream_payload`](crates/agentic-server-core/src/executor/upstream.rs#L58) performs: + +```text +enriched RequestPayload + → to_upstream_request(true) # normalize; set stream=true + → serialize_to_string(...) + → call_inference(...) + → POST upstream /v1/responses + → read raw SSE data lines incrementally + → ResponseAccumulator/FunctionSseTranslator normalize them into typed event frames + ├─ emit eligible typed streaming events toward the client + └─ accumulate all normalized events with ResponseAccumulator + → finalize one complete ResponsePayload + → return StreamPayload { payload, deferred_events } +``` + +The stream still needs a complete in-memory payload because the gateway must inspect all output items after each round: +did the model finish, emit a client-executed function call, or request a gateway-executed built-in tool? The accumulator +also provides the complete state needed for persistence. + +```text +upstream SSE events ───────────────→ eligible events reach the client incrementally + │ + └─ ResponseAccumulator ──→ complete ResponsePayload + ├─ tool-loop decision + └─ persistence +``` + +## 10. Where the HTTP request is really sent + +For a non-streaming round, the call chain is: + +```text +fetch_blocking_payload() + → fetch_response_json() + → send_request() + → client.post(url).headers(...).body(upstream_json) + → optional bearer_auth(...) + → req.send().await +``` + +The actual network operation is this line in +[`send_request`](crates/agentic-server-core/src/executor/inference.rs#L77): + +```rust +let resp = req.send().await ...?; +``` + +Building `client.post(...).body(...)` only creates a request builder. `.send().await` opens/uses the connection and +sends the HTTP request. The upstream is a separate HTTP service, normally vLLM; the Rust gateway does not call the +model as an in-process function. + +Conceptually: + +```text +RequestPayload + │ to_upstream_request() + ▼ +normalized UpstreamRequest + │ serialize_to_string() + ▼ +JSON String + │ reqwest::Client::post().body() + ▼ +HTTP request builder + │ req.send().await + ▼ +upstream LLM: {llm_base_url}/v1/responses +``` + +The streaming path reaches the same `send_request()` through +[`call_inference`](crates/agentic-server-core/src/executor/inference.rs#L146), then consumes `resp.bytes_stream()` as +complete SSE lines. + +## 11. Repeated inference and the built-in tool loop + +“Repeat inference if necessary” is implemented by the `for` loop inside +[`run_gateway_tool_loop`](crates/agentic-server-core/src/executor/engine.rs#L119): + +```rust +for round in 0..MAX_GATEWAY_TOOL_ROUNDS { + // fetch_blocking_payload(...) or fetch_stream_payload(...) + // inspect output, execute applicable built-in tool calls + // classify the round +} +``` + +`MAX_GATEWAY_TOOL_ROUNDS` is 10. Because the main upstream fetch is inside the loop, every new iteration is a new main +response inference round. The cap applies to those tool-loop rounds, not every model invocation: the +`maybe_compact_context(...)` call at the start of a round may first add a blocking automatic-compaction inference. After +the main response, the gateway: + +1. restores model output to its public tool representation where needed; +2. inspects the current output items; +3. identifies client-executed function calls; +4. executes applicable gateway-executed built-in tool calls; +5. adds public output items to the combined response; and +6. calls [`classify_round`](crates/agentic-server-core/src/executor/gateway.rs#L65). + +`LoopDecision` has four outcomes: + +| Decision | Condition | Effect | +| --- | --- | --- | +| `RequiresClientAction` | At least one client-executed function call is present. | Return the turn so the client can execute it and later submit a function call output. This takes precedence if built-in tool calls are also present. | +| `Done` | No gateway-executed built-in tool produced a call output. | Finalize and return the response. | +| `Incomplete(reason)` | Built-in tools ran on the last permitted round. | Return accumulated work with `status: "incomplete"`, keeping a consistent continuation history. | +| `Continue` | Built-in tools ran and rounds remain. | Append replayable model output items plus gateway function-call outputs to the enriched input, then begin the next main response round. | + +More precisely, `append_output_items_to_input` converts every replayable current output item—not only tool calls—back +into input form. `append_tool_outputs` then adds the gateway-produced `function_call_output` items. The `Continue` arm +also sets `tool_choice` to `auto`. There is no explicit Rust `continue;` statement: after the match arm ends, control +reaches the bottom of the `for` body naturally and the next iteration begins. + +Example: + +```text +Round 0 + user input + → inference #1 + → model emits web-search tool call + → gateway executes web search + → append replayable model output + gateway function call output to enriched input + → LoopDecision::Continue + +Round 1 + enriched input now includes the earlier call and output + → inference #2 + → model emits final assistant message + → no gateway-executed built-in tool output + → LoopDecision::Done +``` + +A **turn** is the user-visible unit of work; it can contain several internal **inference rounds**. + +## 12. Persistence and return to the client + +For non-streaming execution, [`run_blocking`](crates/agentic-server-core/src/executor/engine.rs#L402) waits for the tool +loop to finish, calls `persist_if_needed`, and returns the final `ResponsePayload`. + +For streaming execution, `run_stream` relays intermediate events while the loop runs. When the final payload is ready, +it persists first and only then exposes the terminal `response.completed`/`response.incomplete` event. This ordering +prevents a client that disconnects immediately after the terminal event from cancelling persistence. + +[`persist_if_needed`](crates/agentic-server-core/src/executor/persist.rs#L21) persists when the original request has any +of: + +- `store: true`; +- a previous response ID; or +- a conversation ID. + +Completed and incomplete turns are stored. Explicit conversation requests use the conversation handler; other flows, +including previous-response continuations, use the response handler. + +Finally, the Axum handler converts the successful executor result. The error representation depends on whether the SSE +response has already started: + +```text +Either::Left(ResponsePayload) → axum::Json(...) → HTTP JSON response +Either::Right(BoxStream) → sse_response(...) → HTTP SSE response +pre-stream/non-stream error → HTTP status + typed JSON error response +error after SSE is established→ typed SSE error event + data: [DONE] +``` + +Parsing, routing, rehydration, and other failures that happen before `ExecuteRequest::run()` returns a stream can still +be represented as normal HTTP errors. Once the handler has returned a successful SSE response and the stream is being +consumed, its HTTP status is already committed; inference, fatal tool-pipeline/orchestration, persistence, or task +failures are therefore emitted inside the stream as an error event followed by `[DONE]`. Ordinary built-in tool +execution failures and timeouts are normally represented as failed function call outputs and fed back to the model, +rather than becoming stream-level errors. + +## 13. Full end-to-end round trip + +```text +Client + │ POST /v1/responses + ▼ +Axum router: build_router_with_auth + ▼ +responses() handler + ├─ parse raw bytes + typed RequestPayload + ├─ should_execute == false + │ └─ pass request through to upstream and return its response + │ + └─ should_execute == true + ▼ + execute_responses() + ▼ + ExecuteRequest::new(...).with_auth(...).run() + ▼ + rehydrate_conversation() + ├─ preserve original request + ├─ build enriched request + └─ prepend stored item history/effective settings when continuing + ▼ + run_blocking() or run_stream() + ▼ + run_until_gateway_tools_complete() + ├─ compaction_trigger + │ └─ run_compaction_trigger() → direct blocking summarization → return + └─ ordinary request + ▼ + run_gateway_tool_loop() + ├─ build request-scoped ToolRegistry + └─ for each main response/tool-loop round, at most 10: + ├─ maybe_compact_context() may add a blocking compaction inference + ├─ to_upstream_request(stream) + │ ├─ flatten namespace members + │ ├─ validate tools + │ └─ normalize each declaration to zero or more upstream function tools + ├─ serialize normalized request to JSON + ├─ POST {llm_base_url}/v1/responses via req.send().await + ├─ parse full JSON, or normalize raw SSE lines and accumulate ResponsePayload + ├─ inspect model output + ├─ execute gateway-executed built-in tool calls concurrently (ordered, maximum five in flight) + └─ LoopDecision + ├─ Continue + │ ├─ append replayable model output items + │ ├─ append gateway function-call outputs + │ └─ next main response round + ├─ RequiresClientAction → return function call to client + ├─ Done → finalize response + └─ Incomplete → finalize partial response + ▼ + persist completed/incomplete state when required + ▼ + JSON or SSE + ▼ +Client +``` + +If an error occurs before streaming begins, the client receives an HTTP JSON error. If it occurs after SSE has begun, +the client receives an SSE error event followed by `[DONE]`. + +This is the complete logical round trip for the executor path. Individual database queries, specific tool executors, +event translation details, and WebSocket session mechanics are deeper subflows, but they do not change this top-level +lifecycle. + +## 14. Recommended reading order + +1. [`ARCHITECTURE.md`](ARCHITECTURE.md) — crate boundaries and the maintained architecture map. +2. [`TERMINOLOGY.md`](TERMINOLOGY.md) — normative project vocabulary. +3. [`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307) — process startup. +4. [`crates/agentic-server/src/server.rs`](crates/agentic-server/src/server.rs#L35) — state construction and serving. +5. [`crates/agentic-server/src/app.rs`](crates/agentic-server/src/app.rs#L242) — all routes. +6. [`handler/http/responses.rs`](crates/agentic-server/src/handler/http/responses.rs#L49) — pass-through/executor fork. +7. [`executor/engine.rs`](crates/agentic-server-core/src/executor/engine.rs#L119) — orchestration and repeated inference. +8. [`executor/rehydrate.rs`](crates/agentic-server-core/src/executor/rehydrate.rs#L26) — item-history loading. +9. [`types/request_response.rs`](crates/agentic-server-core/src/types/request_response.rs#L108) — request, upstream, and response payloads. +10. [`executor/upstream.rs`](crates/agentic-server-core/src/executor/upstream.rs#L35) — blocking and streaming fetches. +11. [`executor/inference.rs`](crates/agentic-server-core/src/executor/inference.rs#L55) — actual upstream HTTP transport. +12. [`tool/registry.rs`](crates/agentic-server-core/src/tool/registry.rs#L159) and + [`tool/normalize.rs`](crates/agentic-server-core/src/tool/normalize.rs#L75) — routing versus normalization. +13. [`executor/persist.rs`](crates/agentic-server-core/src/executor/persist.rs#L21) — storage decision and write path. + +## 15. Useful run commands + +Run the gateway explicitly: + +```bash +cargo run -p agentic-server --bin agentic-server -- \ + --llm-api-base http://127.0.0.1:8000 +``` + +Inspect the launcher: + +```bash +cargo run -p agentic-server --bin agentic -- --help +``` + +## 16. Read-only inspection commands used during the walkthrough + +The walkthrough used repository search and source inspection; it did not change production code or send a request: + +```bash +git status --short + +rg --files -g 'Cargo.toml' -g '*.rs' -g 'ARCHITECTURE.md' \ + -g 'TERMINOLOGY.md' + +rg -n 'Router|\.route\(|fn main|ExecuteRequest|run_gateway_tool_loop' crates/ + +rg -n 'build_with_handlers|to_upstream_request|to_function_tools|normalize_sse_line' \ + crates/agentic-server-core/src -g '*.rs' + +rg -n 'enum LoopDecision|fn classify_round|execute_output_calls|append_tool_outputs' \ + crates/agentic-server-core/src/executor/gateway.rs + +rg -n 'MAX_GATEWAY_TOOL_ROUNDS' crates/agentic-server-core/src + +sed -n '…' +nl -ba + +cargo metadata --no-deps --format-version 1 +``` + +The only writes made for this documentation request are `round-trip.md` and `round-trip.html`. From e8606261f2e8a7b554f64c8e478ec1f4ae9969b2 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Fri, 28 Aug 2026 08:20:13 +0000 Subject: [PATCH 10/11] Revert "Updates" This reverts commit ef79009b12b7447578e3f223d9ed4adff0d50802. Signed-off-by: haoshan98 --- .../src/executor/rehydrate.rs | 2 +- crates/agentic-server-core/src/tool/mod.rs | 5 +- .../agentic-server-core/src/tool/registry.rs | 62 +- .../src/tool/tool_search.rs | 24 +- .../tool_search_characterization_test.rs | 208 ++---- .../tool_search_state_test.rs} | 74 +- .../tests/tool_search_test.rs | 188 +++++ round-trip.md | 642 ------------------ 8 files changed, 318 insertions(+), 887 deletions(-) rename crates/agentic-server-core/{src/tool/tool_search_state_tests.rs => tests/tool_search_state_test.rs} (94%) delete mode 100644 round-trip.md diff --git a/crates/agentic-server-core/src/executor/rehydrate.rs b/crates/agentic-server-core/src/executor/rehydrate.rs index 25f2e896..0c1472d6 100644 --- a/crates/agentic-server-core/src/executor/rehydrate.rs +++ b/crates/agentic-server-core/src/executor/rehydrate.rs @@ -248,7 +248,7 @@ mod tests { assert!( registry .tool_search_state() - .is_some_and(crate::tool::tool_search::ToolSearchState::is_active) + .is_some_and(crate::tool::ToolSearchState::is_active) ); let upstream = ctx .enriched_request diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index 13229c23..703ff326 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -14,9 +14,6 @@ pub mod registry; pub mod tool_search; pub mod web_search; -#[cfg(test)] -mod tool_search_state_tests; - pub use codex::{CodexNamespaceHandler, NamespaceMap, model_visible_namespace_member_name}; pub use custom::CustomHandler; pub use executors::{GatewayExecutorRegistration, GatewayExecutors}; @@ -24,5 +21,5 @@ pub use function::FunctionHandler; pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput}; pub use mcp::{McpClient, McpClientPool, McpDiscoveredHandler, McpError, McpHandler, McpOperation, McpServerEntry}; pub use registry::{GatewayDispatchResult, ToolEntry, ToolRegistry, ToolType}; -pub use tool_search::ToolSearchHandler; +pub use tool_search::{ToolSearchHandler, ToolSearchState}; pub use web_search::WebSearchHandler; diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 9ac095e9..1aa7e9ce 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -12,11 +12,11 @@ use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; use super::tool_search::{ - TOOL_SEARCH_NAME, ToolSearchState, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, + TOOL_SEARCH_NAME, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, validate_blocking_response, }; use super::web_search::insert_web_search_entry; -use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput}; +use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; use crate::events::WireEvent; use crate::types::event::{MessageStatus, ResponseStatus}; @@ -319,7 +319,7 @@ impl ToolRegistry { ) -> Result { let state = super::ToolSearchHandler::prepare_request(request, restored_loaded_tools, restore_only_declared)?; let mut registry = Self::default(); - registry.install_tool_search_state(state.map(Box::new)); + registry.install_tool_search_state(state.map(Box::new), false)?; Ok(registry) } @@ -334,12 +334,22 @@ impl ToolRegistry { Some(tools) => Self::build_with_handlers(tools, executors).await?, None => Self::default(), }; - registry.install_tool_search_state(self.tool_search.take()); + registry.install_tool_search_state(self.tool_search.take(), true)?; Ok(registry) } - fn install_tool_search_state(&mut self, state: Option>) { - self.tool_search = state; + fn install_tool_search_state( + &mut self, + state: Option>, + validate_private_routes: bool, + ) -> Result<(), ToolError> { + if let Some(state) = state { + if validate_private_routes { + self.validate_tool_search_state(&state)?; + } + self.tool_search = Some(state); + } + Ok(()) } /// Public declarations to expose in response metadata. `Some([])` is @@ -520,6 +530,34 @@ impl ToolRegistry { &self.mcp_list_tools_items } + /// Validate the private dispatch table against prepared tool-search state. + fn validate_tool_search_state(&self, state: &ToolSearchState) -> Result<(), ToolError> { + if !state.is_active() { + return Ok(()); + } + if self + .entries + .keys() + .any(|name| state.withheld_function_names().contains(name)) + { + return Err(ToolError::Config( + "a loaded tool collides with a withheld function name".to_owned(), + )); + } + let Some(_) = state.synthetic_tool_search() else { + return Ok(()); + }; + let entry = self.entries.get(TOOL_SEARCH_NAME).ok_or_else(|| { + ToolError::Config("prepared tool-search declaration is missing from the private registry".to_owned()) + })?; + if entry.tool_type != ToolType::ToolSearch || entry.tool_type.is_gateway_owned() || entry.handler.is_some() { + return Err(ToolError::Config( + "prepared tool-search declaration has invalid private registry ownership".to_owned(), + )); + } + Ok(()) + } + pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) { CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref()); } @@ -657,7 +695,9 @@ mod tests { .await .expect("typed tool-search declaration builds normally"); - registry.install_tool_search_state(Some(Box::new(state))); + registry + .install_tool_search_state(Some(Box::new(state)), true) + .expect("prepared tool-search entry is already classified"); let entry = registry.lookup("tool_search").expect("tool-search entry"); assert_eq!(entry.tool_type, ToolType::ToolSearch); assert!(!entry.tool_type.is_gateway_owned()); @@ -700,7 +740,9 @@ mod tests { .await .expect("loaded function registry"); assert!(registry.lookup("tool_search").is_none()); - registry.install_tool_search_state(Some(Box::new(state))); + registry + .install_tool_search_state(Some(Box::new(state)), true) + .expect("enable replay translation"); let valid: FunctionToolCall = serde_json::from_value(serde_json::json!({ "type": "function_call", "id": "fc_search", "call_id": "call_search", @@ -762,7 +804,9 @@ mod tests { let mut registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .expect("private registry"); - registry.install_tool_search_state(Some(Box::new(state))); + registry + .install_tool_search_state(Some(Box::new(state)), true) + .expect("state application"); assert!( !registry diff --git a/crates/agentic-server-core/src/tool/tool_search.rs b/crates/agentic-server-core/src/tool/tool_search.rs index 4b649795..32775fe3 100644 --- a/crates/agentic-server-core/src/tool/tool_search.rs +++ b/crates/agentic-server-core/src/tool/tool_search.rs @@ -270,7 +270,7 @@ impl CatalogEntry { /// /// The state deliberately has no `Serialize` implementation and its `Debug` /// output contains counts only. -pub(crate) struct ToolSearchState { +pub struct ToolSearchState { activity: ToolSearchActivity, has_completed_search: bool, public_effective_tools: Option>, @@ -334,8 +334,7 @@ impl ToolSearchState { /// Returns [`ToolError::Config`] for an invalid public declaration, /// call/output ordering or linkage error, duplicate/conflicting definition, /// or normalized-name collision. - #[cfg(test)] - pub(crate) fn build(request: &RequestPayload) -> Result { + pub fn build(request: &RequestPayload) -> Result { Self::build_with_loaded_tools(request, &[], false) } @@ -347,9 +346,8 @@ impl ToolSearchState { /// /// # Errors /// - /// Returns [`ToolError::Config`] for invalid declarations, history, - /// definitions, or normalized-name collisions. - pub(crate) fn build_with_loaded_tools( + /// Returns [`ToolError::Config`] under the same conditions as [`Self::build`]. + pub fn build_with_loaded_tools( request: &RequestPayload, restored_loaded_tools: &[ResponsesTool], restore_only_declared: bool, @@ -445,13 +443,12 @@ impl ToolSearchState { } #[must_use] - pub(crate) const fn is_active(&self) -> bool { + pub const fn is_active(&self) -> bool { matches!(self.activity, ToolSearchActivity::Active) } #[must_use] - #[cfg(test)] - pub(crate) fn public_effective_tools(&self) -> Option<&[ResponsesTool]> { + pub fn public_effective_tools(&self) -> Option<&[ResponsesTool]> { self.public_effective_tools.as_deref() } @@ -473,15 +470,13 @@ impl ToolSearchState { /// This remains separate from `public_effective_tools`: an initially /// deferred definition stays deferred publicly even after becoming loaded. #[must_use] - #[cfg(test)] - pub(crate) fn loaded_public_tools(&self) -> &[ResponsesTool] { + pub fn loaded_public_tools(&self) -> &[ResponsesTool] { &self.loaded_public_tools } /// Private tool-search declaration used by request-scoped registry and upstream normalization. #[must_use] - #[cfg(test)] - pub(crate) const fn synthetic_tool_search(&self) -> Option<&ToolSearchToolParam> { + pub const fn synthetic_tool_search(&self) -> Option<&ToolSearchToolParam> { self.synthetic_tool_search.as_ref() } @@ -498,7 +493,7 @@ impl ToolSearchState { /// /// Returns [`ToolError::Config`] when the effective tool choice conflicts /// with the prepared private tool set. - pub(crate) fn prepare_inference_request(&mut self, request: &mut RequestPayload) -> Result<(), ToolError> { + pub fn prepare_inference_request(&mut self, request: &mut RequestPayload) -> Result<(), ToolError> { validate_effective_tool_choice(request.tool_choice.as_ref(), &self.withheld_function_names)?; let input = self.private_upstream_input.take().ok_or_else(|| { ToolError::Config("tool-search private inference input has already been consumed".to_owned()) @@ -558,6 +553,7 @@ fn request_contains_tool_search_state(request: &RequestPayload, input: &Response .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) } + pub(crate) fn request_has_tool_search_state(request: &RequestPayload) -> bool { request_contains_tool_search_state(request, &request.input) } diff --git a/crates/agentic-server-core/tests/tool_search_characterization_test.rs b/crates/agentic-server-core/tests/tool_search_characterization_test.rs index 14f370e0..c7366de6 100644 --- a/crates/agentic-server-core/tests/tool_search_characterization_test.rs +++ b/crates/agentic-server-core/tests/tool_search_characterization_test.rs @@ -1,12 +1,12 @@ mod support; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use agentic_core::RequestPayload; -use agentic_core::tool::model_visible_namespace_member_name; +use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; use serde_json::Value; #[derive(Clone, Copy)] @@ -678,10 +678,33 @@ fn fixture_json(directory: &Path, filename: &str) -> Value { .unwrap_or_else(|error| panic!("{filename} should be valid JSON: {error}")) } +fn lowered_fixture_tools(tools: &Value, input: &Value) -> Value { + let mut request: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "fixture-model", + "input": input, + "tools": tools, + "store": false, + "stream": false, + "parallel_tool_calls": false + })) + .expect("fixture should deserialize as a public request"); + let mut state = ToolSearchState::build(&request).expect("fixture should build tool-search state"); + state + .prepare_inference_request(&mut request) + .expect("fixture should prepare private inference state"); + let upstream = request + .to_upstream_request(false) + .expect("fixture should lower into an upstream request"); + serde_json::to_value(upstream).expect("upstream fixture should serialize")["tools"].clone() +} + #[test] -fn mixed_catalog_tool_choice_fixtures_match_public_types() { +fn mixed_catalog_fixtures_match_private_tool_search_lowering() { let directory = tool_search_cassette_directory(); let public_tools = fixture_json(&directory, "openai_tools.json"); + let returned_tools = fixture_json(&directory, "returned_tools.json"); + let expected_initial = fixture_json(&directory, "vllm_initial_tools.json"); + let expected_loaded = fixture_json(&directory, "vllm_tools_after_search.json"); let openai_tool_choices = fixture_json(&directory, "openai_tool_choice_sequence.json"); let gateway_tool_choices = fixture_json(&directory, "gateway_tool_choice_sequence.json"); @@ -699,6 +722,34 @@ fn mixed_catalog_tool_choice_fixtures_match_public_types() { })) .expect("every public tool-choice fixture should match the typed request model"); } + + assert_eq!( + lowered_fixture_tools(&public_tools, &serde_json::json!("find weather and timezone tools")), + expected_initial + ); + assert_eq!( + lowered_fixture_tools( + &public_tools, + &serde_json::json!([ + { + "type": "tool_search_call", + "id": "tsc_fixture", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "arguments": {"query": "weather and timezone"} + }, + { + "type": "tool_search_output", + "call_id": "call_fixture", + "execution": "client", + "status": "completed", + "tools": returned_tools + } + ]) + ), + expected_loaded + ); } fn public_semantic_fixture(returned_tools: &Value) -> (Vec, Vec) { @@ -1418,80 +1469,6 @@ const PROVIDER_PARITY_CASSETTES: [&str; 7] = [ GATEWAY_WEBSOCKET_CASSETTE, ]; -fn visit_recorded_values(value: &Value, visitor: &mut impl FnMut(&serde_json::Map)) { - match value { - Value::Object(object) => { - visitor(object); - for child in object.values() { - visit_recorded_values(child, visitor); - } - } - Value::Array(array) => { - for child in array { - visit_recorded_values(child, visitor); - } - } - Value::String(text) => { - if matches!(text.trim().as_bytes().first(), Some(b'{' | b'[')) - && let Ok(decoded) = serde_json::from_str::(text) - { - visit_recorded_values(&decoded, visitor); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) => {} - } -} - -fn validate_gateway_cassette_has_no_private_search_projection(raw: &Value) -> Result<(), String> { - let mut public_search_call_ids = HashSet::new(); - let mut search_item_ids = HashSet::new(); - visit_recorded_values(raw, &mut |object| { - let item_type = object.get("type").and_then(Value::as_str); - if item_type == Some("tool_search_call") { - if let Some(call_id) = object.get("call_id").and_then(Value::as_str) { - public_search_call_ids.insert(call_id.to_owned()); - } - if let Some(item_id) = object.get("id").and_then(Value::as_str) { - search_item_ids.insert(item_id.to_owned()); - } - } else if item_type == Some("function_call") - && object.get("name").and_then(Value::as_str) == Some("tool_search") - && let Some(item_id) = object.get("id").and_then(Value::as_str) - { - search_item_ids.insert(item_id.to_owned()); - } - }); - - let mut violation = None; - visit_recorded_values(raw, &mut |object| { - let item_type = object.get("type").and_then(Value::as_str); - let name = object.get("name").and_then(Value::as_str); - if matches!(item_type, Some("function" | "function_call")) && name == Some("tool_search") { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked the private synthetic search projection: {object:?}") - }); - } else if item_type == Some("function_call_output") { - let call_id = object.get("call_id").and_then(Value::as_str); - if call_id.is_some_and(|call_id| public_search_call_ids.contains(call_id)) { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked a normalized search function output: {object:?}") - }); - } - } else if matches!( - item_type, - Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") - ) { - let item_id = object.get("item_id").and_then(Value::as_str); - if item_id.is_some_and(|item_id| search_item_ids.contains(item_id)) { - violation.get_or_insert_with(|| { - format!("public gateway cassette leaked normalized search argument events: {object:?}") - }); - } - } - }); - violation.map_or(Ok(()), Err) -} - fn provider_projection(filename: &str) -> Projection { if filename.contains("openai-reference") || filename.contains("gateway") { Projection::Public @@ -1688,26 +1665,6 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow let raw_document = serde_yaml::from_str::(&raw_text).expect("characterization YAML should be valid"); let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); assert_eq!(cassette.turns.len(), 4, "{filename} should contain four turns"); - if filename.contains("gateway") { - let raw_turns = raw_document["turns"] - .as_array() - .expect("raw gateway cassette should contain turns"); - let mut decoded_surfaces = Vec::new(); - for (raw_turn, turn) in raw_turns.iter().zip(&cassette.turns) { - decoded_surfaces.push(raw_turn["request"]["body"].clone()); - if let Some(body) = &turn.response.body { - decoded_surfaces.push(body.clone()); - } - if turn.response.sse.is_some() { - decoded_surfaces.extend(support::recorded_named_sse_events(turn)); - } - if let Some(websocket) = raw_turn["response"]["websocket"].as_array() { - decoded_surfaces.extend(websocket.iter().cloned()); - } - } - validate_gateway_cassette_has_no_private_search_projection(&Value::Array(decoded_surfaces)) - .unwrap_or_else(|error| panic!("{filename}: {error}")); - } let responses = cassette.turns.iter().map(terminal_response).collect::>(); let inputs = cassette.turns[1..] .iter() @@ -1721,32 +1678,6 @@ fn normalize_provider_cassette(directory: &Path, filename: &str) -> SemanticFlow #[test] fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { let directory = tool_search_cassette_directory(); - let expected_names = PROVIDER_PARITY_CASSETTES - .iter() - .map(|filename| (*filename).to_owned()) - .collect::>(); - let actual_names = fs::read_dir(&directory) - .expect("tool-search cassette directory") - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { - return None; - } - let cassette = support::load_cassette(path.to_str().expect("cassette path should be UTF-8")); - (cassette.turns.len() == 4).then(|| { - path.file_name() - .and_then(|filename| filename.to_str()) - .expect("cassette filename should be UTF-8") - .to_owned() - }) - }) - .collect::>(); - assert_eq!( - actual_names, expected_names, - "the final seven-cassette flow matrix must be exact" - ); - let expected_tools = serde_json::from_str::( &fs::read_to_string(directory.join("returned_tools.json")).expect("returned tool fixture should be readable"), ) @@ -1779,34 +1710,3 @@ fn provider_parity_recorder_generated_matrix_has_one_semantic_flow() { } } } - -#[test] -fn provider_parity_non_leak_detector_rejects_nested_private_shapes() { - let cases = [ - serde_json::json!({"response": {"tools": [{"type": "function", "name": "tool_search"}]}}), - serde_json::json!({"response": {"output": [{ - "type": "function_call", "id": "fc_private", "call_id": "call_search", "name": "tool_search" - }]}}), - serde_json::json!({ - "request": {"input": [{ - "type": "tool_search_call", "id": "tsc_public", "call_id": "call_search" - }, { - "type": "function_call_output", "call_id": "call_search", "output": "{}" - }]} - }), - serde_json::json!([{ - "type": "response.output_item.added", - "item": {"type": "tool_search_call", "id": "tsc_public", "call_id": "call_search"} - }, { - "type": "response.function_call_arguments.delta", "item_id": "tsc_public", "delta": "{}" - }]), - serde_json::json!([r#"{"type":"function_call","id":"fc_ws","call_id":"call_ws","name":"tool_search"}"#]), - ]; - - for case in cases { - assert!( - validate_gateway_cassette_has_no_private_search_projection(&case).is_err(), - "private nested shape should be rejected: {case}" - ); - } -} diff --git a/crates/agentic-server-core/src/tool/tool_search_state_tests.rs b/crates/agentic-server-core/tests/tool_search_state_test.rs similarity index 94% rename from crates/agentic-server-core/src/tool/tool_search_state_tests.rs rename to crates/agentic-server-core/tests/tool_search_state_test.rs index d5d5f90a..8bc16be4 100644 --- a/crates/agentic-server-core/src/tool/tool_search_state_tests.rs +++ b/crates/agentic-server-core/tests/tool_search_state_test.rs @@ -1,9 +1,6 @@ -use super::model_visible_namespace_member_name; -use super::tool_search::ToolSearchState; -use crate::{InputItem, RequestPayload, ResponsesInput}; +use agentic_core::tool::{ToolSearchState, model_visible_namespace_member_name}; +use agentic_core::{InputItem, RequestPayload, ResponsesInput}; use serde_json::{Value, json}; -use std::fs; -use std::path::Path; fn request(tools: Value, input: Value) -> RequestPayload { let mut value = json!({ @@ -64,7 +61,7 @@ fn search_output(id: &str, tools: Vec) -> Value { value } -fn tool_values(tools: Option<&[crate::ResponsesTool]>) -> Value { +fn tool_values(tools: Option<&[agentic_core::ResponsesTool]>) -> Value { serde_json::to_value(tools).expect("prepared tools serialize") } @@ -92,57 +89,6 @@ fn synthetic_description(state: &ToolSearchState) -> &str { .expect("active tool search has a synthetic description") } -fn fixture_json(filename: &str) -> Value { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/cassettes/tool_search") - .join(filename); - serde_json::from_str(&fs::read_to_string(path).expect("tool-search fixture should be readable")) - .expect("tool-search fixture should be valid JSON") -} - -fn lowered_fixture_tools(tools: Value, input: Value) -> Value { - let public = request(tools, input); - let mut state = ToolSearchState::build(&public).expect("fixture should build tool-search state"); - let private = private_request(&mut state, &public); - let upstream = private - .to_upstream_request(false) - .expect("fixture should lower into an upstream request"); - serde_json::to_value(upstream).expect("upstream fixture should serialize")["tools"].clone() -} - -#[test] -fn mixed_catalog_fixtures_match_private_tool_search_lowering() { - let public_tools = fixture_json("openai_tools.json"); - let returned_tools = fixture_json("returned_tools.json"); - assert_eq!( - lowered_fixture_tools(public_tools.clone(), json!("find weather and timezone tools")), - fixture_json("vllm_initial_tools.json") - ); - assert_eq!( - lowered_fixture_tools( - public_tools, - json!([ - { - "type": "tool_search_call", - "id": "tsc_fixture", - "call_id": "call_fixture", - "execution": "client", - "status": "completed", - "arguments": {"query": "weather and timezone"} - }, - { - "type": "tool_search_output", - "call_id": "call_fixture", - "execution": "client", - "status": "completed", - "tools": returned_tools - } - ]), - ), - fixture_json("vllm_tools_after_search.json") - ); -} - #[test] fn fresh_and_sequential_state_has_distinct_deterministic_views() { let deferred = function("get_weather", "Get weather", "string", true); @@ -1234,7 +1180,7 @@ fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { "encrypted_content": "The weather tool was loaded earlier." }]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "get_weather", "description": "Get weather", @@ -1253,7 +1199,9 @@ fn replay_restores_loaded_deferred_tool_after_compaction_removed_search_pair() { let loaded = private .iter() .find_map(|tool| match tool { - crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather" => { + agentic_core::types::tools::ResponsesTool::Function(function) + if function.name.as_str() == "get_weather" => + { Some(function) } _ => None, @@ -1295,7 +1243,7 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { } ]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "get_weather", "description": "Get weather", @@ -1311,13 +1259,13 @@ fn compacted_replay_does_not_reload_definition_omitted_by_explicit_tools() { .public_effective_tools() .unwrap() .iter() - .all(|tool| !matches!(tool, crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); + .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); assert!(private_request(&mut state, &request) .tools .as_deref() .expect("private tools") .iter() - .all(|tool| !matches!(tool, crate::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); + .all(|tool| !matches!(tool, agentic_core::types::tools::ResponsesTool::Function(function) if function.name.as_str() == "get_weather"))); } #[test] @@ -1341,7 +1289,7 @@ fn replayed_loaded_marker_rejects_explicit_cross_kind_identity_collision() { "encrypted_content": "A function with this name was loaded earlier." }]), ); - let restored: Vec = serde_json::from_value(json!([{ + let restored: Vec = serde_json::from_value(json!([{ "type": "function", "name": "shared_identity", "description": "Original function", diff --git a/crates/agentic-server-core/tests/tool_search_test.rs b/crates/agentic-server-core/tests/tool_search_test.rs index 1aa829bc..2bb5e0cd 100644 --- a/crates/agentic-server-core/tests/tool_search_test.rs +++ b/crates/agentic-server-core/tests/tool_search_test.rs @@ -1,6 +1,11 @@ +mod support; + +use std::collections::HashSet; use std::collections::VecDeque; use std::fmt::Write as _; +use std::fs; use std::future::Future; +use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1162,3 +1167,186 @@ async fn dynamic_namespace_forward_references_fail_before_inference() { } assert!(requests.lock().await.is_empty(), "inference must not run"); } + +fn visit_recorded_values(value: &Value, visitor: &mut impl FnMut(&serde_json::Map)) { + match value { + Value::Object(object) => { + visitor(object); + for child in object.values() { + visit_recorded_values(child, visitor); + } + } + Value::Array(array) => { + for child in array { + visit_recorded_values(child, visitor); + } + } + Value::String(text) => { + if matches!(text.trim().as_bytes().first(), Some(b'{' | b'[')) + && let Ok(decoded) = serde_json::from_str::(text) + { + visit_recorded_values(&decoded, visitor); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn validate_gateway_cassette_has_no_private_search_projection(raw: &Value) -> Result<(), String> { + let mut public_search_call_ids = HashSet::new(); + let mut search_item_ids = HashSet::new(); + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + if item_type == Some("tool_search_call") { + if let Some(call_id) = object.get("call_id").and_then(Value::as_str) { + public_search_call_ids.insert(call_id.to_owned()); + } + if let Some(item_id) = object.get("id").and_then(Value::as_str) { + search_item_ids.insert(item_id.to_owned()); + } + } else if item_type == Some("function_call") + && object.get("name").and_then(Value::as_str) == Some("tool_search") + && let Some(item_id) = object.get("id").and_then(Value::as_str) + { + search_item_ids.insert(item_id.to_owned()); + } + }); + + let mut violation = None; + visit_recorded_values(raw, &mut |object| { + let item_type = object.get("type").and_then(Value::as_str); + let name = object.get("name").and_then(Value::as_str); + if matches!(item_type, Some("function" | "function_call")) && name == Some("tool_search") { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked the private synthetic search projection: {object:?}") + }); + } else if item_type == Some("function_call_output") { + let call_id = object.get("call_id").and_then(Value::as_str); + if call_id.is_some_and(|call_id| public_search_call_ids.contains(call_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked a normalized search function output: {object:?}") + }); + } + } else if matches!( + item_type, + Some("response.function_call_arguments.delta" | "response.function_call_arguments.done") + ) { + let item_id = object.get("item_id").and_then(Value::as_str); + if item_id.is_some_and(|item_id| search_item_ids.contains(item_id)) { + violation.get_or_insert_with(|| { + format!("public gateway cassette leaked normalized search argument events: {object:?}") + }); + } + } + }); + violation.map_or(Ok(()), Err) +} + +#[test] +fn provider_parity_matrix_is_exact_and_gateway_has_no_private_search_leaks() { + const FLOW_CASSETTES: [(&str, bool); 7] = [ + ("tool-search-openai-reference-gpt-5.6-nonstreaming.yaml", false), + ("tool-search-openai-reference-gpt-5.6-streaming.yaml", false), + ( + "tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", + false, + ), + ("tool-search-direct-vllm-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", false), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-nonstreaming.yaml", true), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-streaming.yaml", true), + ("tool-search-gateway-Qwen-Qwen3.6-35B-A3B-FP8-websocket.yaml", true), + ]; + + let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes/tool_search"); + let expected_names = FLOW_CASSETTES + .iter() + .map(|(filename, _)| (*filename).to_owned()) + .collect::>(); + let actual_names = fs::read_dir(&directory) + .expect("tool-search cassette directory") + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { + return None; + } + let cassette = support::load_cassette(path.to_str().expect("cassette path")); + (cassette.turns.len() == 4).then(|| { + path.file_name() + .and_then(|filename| filename.to_str()) + .expect("cassette filename") + .to_owned() + }) + }) + .collect::>(); + assert_eq!( + actual_names, expected_names, + "the final seven-cassette flow matrix must be exact" + ); + + for (filename, gateway) in FLOW_CASSETTES { + let path = directory.join(filename); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&path).expect("cassette metadata").permissions().mode() & 0o111, + 0, + "checked-in cassette must not be executable: {filename}" + ); + } + if gateway { + let raw = + serde_yaml::from_str::(&fs::read_to_string(&path).expect("gateway cassette should be readable")) + .expect("gateway cassette YAML"); + let raw_turns = raw["turns"].as_array().expect("raw gateway cassette turns"); + let cassette = support::load_cassette(path.to_str().expect("gateway cassette path")); + let mut decoded_surfaces = Vec::new(); + for (raw_turn, turn) in raw_turns.iter().zip(&cassette.turns) { + decoded_surfaces.push(raw_turn["request"]["body"].clone()); + if let Some(body) = &turn.response.body { + decoded_surfaces.push(body.clone()); + } + if turn.response.sse.is_some() { + decoded_surfaces.extend(support::recorded_named_sse_events(turn)); + } + if let Some(websocket) = raw_turn["response"]["websocket"].as_array() { + decoded_surfaces.extend(websocket.iter().cloned()); + } + } + validate_gateway_cassette_has_no_private_search_projection(&Value::Array(decoded_surfaces)) + .unwrap_or_else(|error| panic!("{error}")); + } + } +} + +#[test] +fn provider_parity_non_leak_detector_rejects_nested_private_shapes() { + let cases = [ + json!({"response": {"tools": [{"type": "function", "name": "tool_search"}]}}), + json!({"response": {"output": [{ + "type": "function_call", "id": "fc_private", "call_id": "call_search", "name": "tool_search" + }]}}), + json!({ + "request": {"input": [{ + "type": "tool_search_call", "id": "tsc_public", "call_id": "call_search" + }, { + "type": "function_call_output", "call_id": "call_search", "output": "{}" + }]} + }), + json!([{ + "type": "response.output_item.added", + "item": {"type": "tool_search_call", "id": "tsc_public", "call_id": "call_search"} + }, { + "type": "response.function_call_arguments.delta", "item_id": "tsc_public", "delta": "{}" + }]), + json!([r#"{"type":"function_call","id":"fc_ws","call_id":"call_ws","name":"tool_search"}"#]), + ]; + + for case in cases { + assert!( + validate_gateway_cassette_has_no_private_search_projection(&case).is_err(), + "private nested shape should be rejected: {case}" + ); + } +} diff --git a/round-trip.md b/round-trip.md deleted file mode 100644 index 69f2b27f..00000000 --- a/round-trip.md +++ /dev/null @@ -1,642 +0,0 @@ -# `POST /v1/responses`: repository entrypoints and full round trip - -This document is a Python-friendly map of the Rust repository and the complete logical path of a `POST /v1/responses` -request. It follows the local executor path, including rehydration, tool normalization, upstream inference, the built-in -tool loop, persistence, and the response returned to the client. It also shows the shorter pass-through path. - -The short answer is that the HTTP gateway starts in -[`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307), while the main Responses orchestration -starts in [`ExecuteRequest::run`](crates/agentic-server-core/src/executor/engine.rs#L555). - -## 1. Rust mental model for a Python developer - -Rust/Cargo separates a repository into a few concepts that Python often leaves implicit: - -| Rust concept | Rough Python analogy | Meaning here | -| --- | --- | --- | -| Cargo workspace | monorepo | The root [`Cargo.toml`](Cargo.toml#L1) groups packages under `crates/`. | -| Package | installable Python distribution | A directory with a `Cargo.toml`, such as `crates/agentic-server`. | -| Crate | one compiled library or executable | A package can produce a library crate and multiple binary crates. | -| Target | one build output | A library, binary, test, example, or benchmark produced by Cargo. | -| `src/main.rs` | `if __name__ == "__main__":` | Default executable entrypoint for a package. | -| `src/bin/name.rs` | another console-script entrypoint | An additional executable named `name`. | -| `src/lib.rs` | public package/module interface | Exports code shared by binaries and other crates. | -| `mod server;` | load a local module | Makes `server.rs` part of the crate's module tree. | -| `use foo::Bar` | `from foo import Bar` | Brings a name into scope. | -| `pub` | exported/public | Makes an item visible outside its module or crate. | -| `#[tokio::main]` | create an async event loop, then run `main()` | Starts the Tokio runtime so `main` can use async I/O. | -| `Result` and `?` | return a value or raise/propagate an exception | `?` returns early when the result is an error. | -| `.await` | `await` | Suspends the current task while I/O proceeds; it does not block the Tokio worker thread. | -| `Arc` | shared, reference-counted application state | `Arc::clone` cheaply clones a pointer, not the underlying database/client state. | - -The key difference is that one Cargo package can have more than one executable. Finding one `main.rs` does not imply -that it is the repository's only entrypoint. - -## 2. Workspace, crates, and executable entrypoints - -The root workspace is declared in [`Cargo.toml`](Cargo.toml#L1) and contains three packages: - -| Package | Role | -| --- | --- | -| `agentic-server` | Axum HTTP/WebSocket transport, configuration, server lifecycle, and two executables. | -| `agentic-server-core` (`agentic_core` in Rust imports) | Framework-independent types, rehydration, inference, tools, tool loop, and persistence. | -| `agentic-praxis` | Placeholder for a future Praxis integration. | - -The dependency direction is intentional: the transport crate calls the core crate; core does not depend on Axum. - -`agentic-server` produces two executables: - -| Executable | Entrypoint | Purpose | -| --- | --- | --- | -| `agentic-server` | [`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307) | The HTTP/WebSocket gateway. Cargo infers this default binary from `src/main.rs`. | -| `agentic` | [`crates/agentic-server/src/bin/agentic.rs`](crates/agentic-server/src/bin/agentic.rs#L11) | User-facing launcher for the gateway and coding harnesses. It is declared explicitly in [`crates/agentic-server/Cargo.toml`](crates/agentic-server/Cargo.toml#L52). | - -For API request handling, follow `agentic-server`; the `agentic` launcher is not in the request path. - -## 3. Gateway startup chain - -The gateway process starts at `#[tokio::main] async fn main()`: - -```text -crates/agentic-server/src/main.rs::main - ├─ initialize tracing - ├─ parse CLI flags/environment/config.toml - ├─ construct agentic_core::config::Config - └─ server::run(...) or server::run_with_llm(...) - ├─ optionally discover OIDC configuration - ├─ wait for the upstream LLM to become ready - ├─ build_state(...) - │ ├─ ProxyState::new(...) - │ └─ ExecutionContext::from_config(...) - │ ├─ response/conversation storage handlers - │ ├─ shared reqwest HTTP client - │ ├─ built-in tool executors - │ └─ upstream LLM base URL - ├─ build_router_with_auth(...) - ├─ TcpListener::bind(...) - └─ axum::serve(...) -``` - -The important source points are: - -- Process entry and configuration: [`main.rs`](crates/agentic-server/src/main.rs#L307) -- State construction: [`server.rs::build_state`](crates/agentic-server/src/server.rs#L35) -- Router, listener, and server: [`server.rs::serve_gateway`](crates/agentic-server/src/server.rs#L52) -- Shared executor dependencies: [`ExecutionContext`](crates/agentic-server-core/src/executor/request.rs#L52) - -`ExecutionContext::responses_url()` appends `/v1/responses` to the configured LLM base URL. For example, -`http://127.0.0.1:8000` becomes `http://127.0.0.1:8000/v1/responses`. - -## 4. API and route surface - -Routes are wired in -[`build_router_with_auth`](crates/agentic-server/src/app.rs#L242): - -| Method and path | Purpose | -| --- | --- | -| `POST /v1/responses` | OpenAI-compatible Responses API over JSON or server-sent events (SSE). | -| `GET /v1/responses` | Responses API WebSocket upgrade. It reaches the same executor from a long-lived session loop. | -| `POST /v1/responses/compact` | Explicit context compaction. | -| `POST /v1/conversations` | Create a durable conversation. | -| `POST /v1/messages` | Anthropic-compatible Messages API. | -| `POST /v1/messages/count_tokens` | Anthropic-compatible token counting. | -| `GET /v1/models` | Upstream model listing with gateway handling as configured. | -| `GET /health` | Process liveness. | -| `GET /ready` | Storage and upstream readiness. | - -`/health` and `/ready` are public. The remaining routes are protected when OIDC authentication is configured. - -## 5. The first fork: pass through or execute locally - -The HTTP handler is -[`responses`](crates/agentic-server/src/handler/http/responses.rs#L49). It first retains two representations of the -same body: - -- `bytes`: the original raw request body, used when passing the request through unchanged. -- `payload`: a deserialized, typed [`RequestPayload`](crates/agentic-server-core/src/types/request_response.rs#L14), - used when the gateway must inspect or modify the request. - -The fork is exactly: - -```rust -if should_execute { - execute_responses(&state, parts, payload).await -} else { - proxy_responses(&state, parts, bytes).await -} -``` - -`should_execute` is true when any of these apply: - -- `store` is true; -- `previous_response_id` is present; -- `conversation_id` is present; -- the input contains a compaction item or compaction trigger; -- non-empty `context_management` is present; or -- the request contains any tool declaration other than a plain `ResponsesTool::Function`. - -That last test is structural: `Custom`, `Namespace`, and `Unknown` also select the executor path, even though they are -not all built-in tools and are not all locally executable. Selecting the executor does not by itself mean the gateway -will execute every declared tool. - -`RequestPayload.store` defaults to `true`, so a simple request normally uses the local executor unless it explicitly -sends `"store": false`. A request with `store: false` can still use the executor when another condition above requires -local behavior. - -The two branches are: - -```text -responses() -├─ should_execute == false -│ └─ proxy_responses() -│ └─ proxy_request() -│ └─ pass request through to upstream; no local executor or persistence -│ -└─ should_execute == true - └─ execute_responses() - └─ ExecuteRequest::new(payload, Arc::clone(&state.exec_ctx)) - └─ .with_auth(auth) - └─ .run().await -``` - -The exact link to `ExecuteRequest::run()` is in -[`execute_responses`](crates/agentic-server/src/handler/http/responses.rs#L29). Its result has two successful shapes: - -- `Either::Left(ResponsePayload)`: a complete non-streaming JSON response. -- `Either::Right(BoxStream)`: a stream of complete SSE frames. - -### Inbound identity versus upstream LLM credentials - -There are two different authentication concerns: - -1. **Inbound OIDC identity authentication** decides whether the caller may use the gateway. -2. **Upstream LLM authentication** supplies the credential used when the gateway calls vLLM or another inference - service. - -When OIDC is enabled for the OpenAI-compatible routes, the -[`require_oidc`](crates/agentic-server/src/auth.rs#L351) middleware verifies the caller's bearer token, stores the -authenticated principal in request extensions, and removes the inbound `Authorization` and OpenAI `x-api-key` headers. -They therefore cannot be mistaken for an upstream LLM credential. The handler then falls back to the configured -`OPENAI_API_KEY`; if that key is configured and non-empty, it is used for the upstream call. Otherwise the executor -sends no upstream bearer credential. - -Without OIDC, the executor path's `extract_bearer` uses a caller bearer token when supplied and otherwise falls back to -the configured `OPENAI_API_KEY`. On the pass-through path, eligible caller credential headers are preserved; the proxy -injects the configured key only when the client supplied neither `Authorization` nor `x-api-key`. - -## 6. `ExecuteRequest::run()` and rehydration - -[`ExecuteRequest::run`](crates/agentic-server-core/src/executor/engine.rs#L555) begins with: - -```rust -let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?; -``` - -Yes: this is the item-history rehydration step. The project-specific meaning of **rehydration** is loading stored items, -restoring their order and effective request settings, and building the input for a continuation. - -[`rehydrate_conversation`](crates/agentic-server-core/src/executor/rehydrate.rs#L26) builds a `RequestContext` with: - -- `original_request`: an unchanged copy used for storage semantics and response metadata; -- `enriched_request`: the request that can be augmented before upstream inference; -- `new_input_items`: only the newly submitted input, retained for persistence; -- a generated response ID; -- optional conversation ID and version information. - -It then selects one path: - -```text -conversation_id present - → load a conversation snapshot and prepend its item history - -previous_response_id present - → rehydrate the stored-response chain - → restore effective tools/tool_choice when appropriate - → prepend its item history - -neither ID present - → use only the new input items -``` - -Supplying both IDs is rejected. After rehydration, `run()` chooses the client transport requested by -`original_request.stream`: - -```text -stream == false → run_blocking(...) → Either::Left(ResponsePayload) -stream == true → run_stream(...) → Either::Right(BoxStream) -``` - -Here “blocking” means **non-streaming API behavior**—wait for one complete upstream JSON body. It does not mean blocking -the Tokio worker thread; the network operations are asynchronous. - -Both modes then use -[`run_until_gateway_tools_complete`](crates/agentic-server-core/src/executor/engine.rs#L98), which has another important -branch: - -```text -input has compaction_trigger - → run_compaction_trigger(...) - → run one direct blocking summarization inference - → return a compaction response without entering the ordinary tool loop - -ordinary input - → run_gateway_tool_loop(...) -``` - -Inside the ordinary loop, `maybe_compact_context(...)` can also perform an automatic blocking compaction inference -before the main response inference for a round. Consequently, the ten-round limit described later caps ordinary -tool-loop/main-response rounds, not every upstream model invocation: automatic compaction can add a model call. - -## 7. Tool registry versus tool normalization - -These are separate operations with separate purposes. - -### 7.1 Request-scoped tool registry: how calls will be routed - -Before the inference rounds, [`run_gateway_tool_loop`](crates/agentic-server-core/src/executor/engine.rs#L119) calls: - -```rust -ToolRegistry::build_with_handlers(tools, &mut executors).await? -``` - -The [`ToolRegistry`](crates/agentic-server-core/src/tool/registry.rs#L159) maps each model-visible tool name to routing -metadata. It can: - -- flatten Codex namespace member names consistently; -- discover tools from Model Context Protocol (MCP) servers; -- associate names with their original tool type and configuration; -- attach an executor for a gateway-executed built-in tool; and -- retain mappings needed to restore the public output shape after inference. - -The registry answers: **“When the model emits this tool name, what is it and who executes it?”** It is request-scoped -runtime routing state, not part of the Responses wire format. - -### 7.2 Tool normalization: what shape the upstream accepts - -Normalization happens later, immediately before each upstream request, in -[`RequestPayload::to_upstream_request`](crates/agentic-server-core/src/types/request_response.rs#L123). Both -`fetch_blocking_payload` and `fetch_stream_payload` call it. - -It: - -1. resolves namespace members to flat, model-visible names; -2. validates the resolved declarations; -3. calls `ResponsesTool::to_function_tools` for every declaration, producing zero or more upstream function tools; -4. wraps each produced function tool as `UpstreamTool::Function`; -5. normalizes `tool_choice`; and -6. always sends `parallel_tool_calls: false`, asking the upstream not to generate parallel function calls. - -The per-tool conversion is in -[`ResponsesTool::to_function_tools`](crates/agentic-server-core/src/tool/normalize.rs#L90). A declaration can expand -to more than one function tool (notably MCP discovery), one function tool, or none. `FileSearch`, `CodeInterpreter`, -and `Unknown` are currently skipped during normalization; they produce no upstream tool declaration. For example, a -public web search declaration becomes one upstream function tool. The public meaning is preserved even though the -upstream wire shape changes. - -`parallel_tool_calls: false` does not mean gateway tool calls are all executed one at a time. If a round nevertheless -contains multiple gateway-executed built-in tool calls, the gateway executes them concurrently with an order-preserving -sliding window bounded at five calls. It then assembles results in model-output order before continuing the loop. - -The distinction is: - -```text -rehydration = Which prior items and effective settings belong in this request? -tool registry = How will a returned tool call be classified, restored, and executed? -tool normalization= What function-tool JSON shape must be sent to the upstream inference server? -``` - -## 8. What “payload” means - -**Payload** is ordinary networking terminology, not a special Rust feature. It means the meaningful data carried by a -request or response, separate from transport details such as HTTP headers and status codes. - -In this path: - -| Name | Meaning | -| --- | --- | -| `RequestPayload` | Typed Rust representation of the client's Responses JSON request body. | -| `UpstreamRequest` | Normalized request body sent to the upstream LLM endpoint. | -| `ResponsePayload` | Typed, complete Responses object for one upstream inference round (later combined across rounds). | -| `StreamPayload` | Internal wrapper containing the accumulated `ResponsePayload` plus deferred streaming events. It is not a separate public API response. | - -`ResponsePayload` includes the response ID, model, status, output items, token usage, continuation/conversation IDs, -and error or incomplete details. - -## 9. Fetching a blocking or streaming payload - -Both functions call the same upstream `/v1/responses` endpoint and ultimately produce a complete `ResponsePayload`. -They differ in how the upstream response is transported. - -### 9.1 `fetch_blocking_payload()` - -[`fetch_blocking_payload`](crates/agentic-server-core/src/executor/upstream.rs#L35) performs: - -```text -enriched RequestPayload - → to_upstream_request(false) # normalize; set stream=false - → serialize_to_string(...) - → fetch_response_json(...) - → POST upstream /v1/responses - → await the complete JSON body - → ResponseAccumulator::from_json(...) - → finalize one ResponsePayload -``` - -### 9.2 `fetch_stream_payload()` - -[`fetch_stream_payload`](crates/agentic-server-core/src/executor/upstream.rs#L58) performs: - -```text -enriched RequestPayload - → to_upstream_request(true) # normalize; set stream=true - → serialize_to_string(...) - → call_inference(...) - → POST upstream /v1/responses - → read raw SSE data lines incrementally - → ResponseAccumulator/FunctionSseTranslator normalize them into typed event frames - ├─ emit eligible typed streaming events toward the client - └─ accumulate all normalized events with ResponseAccumulator - → finalize one complete ResponsePayload - → return StreamPayload { payload, deferred_events } -``` - -The stream still needs a complete in-memory payload because the gateway must inspect all output items after each round: -did the model finish, emit a client-executed function call, or request a gateway-executed built-in tool? The accumulator -also provides the complete state needed for persistence. - -```text -upstream SSE events ───────────────→ eligible events reach the client incrementally - │ - └─ ResponseAccumulator ──→ complete ResponsePayload - ├─ tool-loop decision - └─ persistence -``` - -## 10. Where the HTTP request is really sent - -For a non-streaming round, the call chain is: - -```text -fetch_blocking_payload() - → fetch_response_json() - → send_request() - → client.post(url).headers(...).body(upstream_json) - → optional bearer_auth(...) - → req.send().await -``` - -The actual network operation is this line in -[`send_request`](crates/agentic-server-core/src/executor/inference.rs#L77): - -```rust -let resp = req.send().await ...?; -``` - -Building `client.post(...).body(...)` only creates a request builder. `.send().await` opens/uses the connection and -sends the HTTP request. The upstream is a separate HTTP service, normally vLLM; the Rust gateway does not call the -model as an in-process function. - -Conceptually: - -```text -RequestPayload - │ to_upstream_request() - ▼ -normalized UpstreamRequest - │ serialize_to_string() - ▼ -JSON String - │ reqwest::Client::post().body() - ▼ -HTTP request builder - │ req.send().await - ▼ -upstream LLM: {llm_base_url}/v1/responses -``` - -The streaming path reaches the same `send_request()` through -[`call_inference`](crates/agentic-server-core/src/executor/inference.rs#L146), then consumes `resp.bytes_stream()` as -complete SSE lines. - -## 11. Repeated inference and the built-in tool loop - -“Repeat inference if necessary” is implemented by the `for` loop inside -[`run_gateway_tool_loop`](crates/agentic-server-core/src/executor/engine.rs#L119): - -```rust -for round in 0..MAX_GATEWAY_TOOL_ROUNDS { - // fetch_blocking_payload(...) or fetch_stream_payload(...) - // inspect output, execute applicable built-in tool calls - // classify the round -} -``` - -`MAX_GATEWAY_TOOL_ROUNDS` is 10. Because the main upstream fetch is inside the loop, every new iteration is a new main -response inference round. The cap applies to those tool-loop rounds, not every model invocation: the -`maybe_compact_context(...)` call at the start of a round may first add a blocking automatic-compaction inference. After -the main response, the gateway: - -1. restores model output to its public tool representation where needed; -2. inspects the current output items; -3. identifies client-executed function calls; -4. executes applicable gateway-executed built-in tool calls; -5. adds public output items to the combined response; and -6. calls [`classify_round`](crates/agentic-server-core/src/executor/gateway.rs#L65). - -`LoopDecision` has four outcomes: - -| Decision | Condition | Effect | -| --- | --- | --- | -| `RequiresClientAction` | At least one client-executed function call is present. | Return the turn so the client can execute it and later submit a function call output. This takes precedence if built-in tool calls are also present. | -| `Done` | No gateway-executed built-in tool produced a call output. | Finalize and return the response. | -| `Incomplete(reason)` | Built-in tools ran on the last permitted round. | Return accumulated work with `status: "incomplete"`, keeping a consistent continuation history. | -| `Continue` | Built-in tools ran and rounds remain. | Append replayable model output items plus gateway function-call outputs to the enriched input, then begin the next main response round. | - -More precisely, `append_output_items_to_input` converts every replayable current output item—not only tool calls—back -into input form. `append_tool_outputs` then adds the gateway-produced `function_call_output` items. The `Continue` arm -also sets `tool_choice` to `auto`. There is no explicit Rust `continue;` statement: after the match arm ends, control -reaches the bottom of the `for` body naturally and the next iteration begins. - -Example: - -```text -Round 0 - user input - → inference #1 - → model emits web-search tool call - → gateway executes web search - → append replayable model output + gateway function call output to enriched input - → LoopDecision::Continue - -Round 1 - enriched input now includes the earlier call and output - → inference #2 - → model emits final assistant message - → no gateway-executed built-in tool output - → LoopDecision::Done -``` - -A **turn** is the user-visible unit of work; it can contain several internal **inference rounds**. - -## 12. Persistence and return to the client - -For non-streaming execution, [`run_blocking`](crates/agentic-server-core/src/executor/engine.rs#L402) waits for the tool -loop to finish, calls `persist_if_needed`, and returns the final `ResponsePayload`. - -For streaming execution, `run_stream` relays intermediate events while the loop runs. When the final payload is ready, -it persists first and only then exposes the terminal `response.completed`/`response.incomplete` event. This ordering -prevents a client that disconnects immediately after the terminal event from cancelling persistence. - -[`persist_if_needed`](crates/agentic-server-core/src/executor/persist.rs#L21) persists when the original request has any -of: - -- `store: true`; -- a previous response ID; or -- a conversation ID. - -Completed and incomplete turns are stored. Explicit conversation requests use the conversation handler; other flows, -including previous-response continuations, use the response handler. - -Finally, the Axum handler converts the successful executor result. The error representation depends on whether the SSE -response has already started: - -```text -Either::Left(ResponsePayload) → axum::Json(...) → HTTP JSON response -Either::Right(BoxStream) → sse_response(...) → HTTP SSE response -pre-stream/non-stream error → HTTP status + typed JSON error response -error after SSE is established→ typed SSE error event + data: [DONE] -``` - -Parsing, routing, rehydration, and other failures that happen before `ExecuteRequest::run()` returns a stream can still -be represented as normal HTTP errors. Once the handler has returned a successful SSE response and the stream is being -consumed, its HTTP status is already committed; inference, fatal tool-pipeline/orchestration, persistence, or task -failures are therefore emitted inside the stream as an error event followed by `[DONE]`. Ordinary built-in tool -execution failures and timeouts are normally represented as failed function call outputs and fed back to the model, -rather than becoming stream-level errors. - -## 13. Full end-to-end round trip - -```text -Client - │ POST /v1/responses - ▼ -Axum router: build_router_with_auth - ▼ -responses() handler - ├─ parse raw bytes + typed RequestPayload - ├─ should_execute == false - │ └─ pass request through to upstream and return its response - │ - └─ should_execute == true - ▼ - execute_responses() - ▼ - ExecuteRequest::new(...).with_auth(...).run() - ▼ - rehydrate_conversation() - ├─ preserve original request - ├─ build enriched request - └─ prepend stored item history/effective settings when continuing - ▼ - run_blocking() or run_stream() - ▼ - run_until_gateway_tools_complete() - ├─ compaction_trigger - │ └─ run_compaction_trigger() → direct blocking summarization → return - └─ ordinary request - ▼ - run_gateway_tool_loop() - ├─ build request-scoped ToolRegistry - └─ for each main response/tool-loop round, at most 10: - ├─ maybe_compact_context() may add a blocking compaction inference - ├─ to_upstream_request(stream) - │ ├─ flatten namespace members - │ ├─ validate tools - │ └─ normalize each declaration to zero or more upstream function tools - ├─ serialize normalized request to JSON - ├─ POST {llm_base_url}/v1/responses via req.send().await - ├─ parse full JSON, or normalize raw SSE lines and accumulate ResponsePayload - ├─ inspect model output - ├─ execute gateway-executed built-in tool calls concurrently (ordered, maximum five in flight) - └─ LoopDecision - ├─ Continue - │ ├─ append replayable model output items - │ ├─ append gateway function-call outputs - │ └─ next main response round - ├─ RequiresClientAction → return function call to client - ├─ Done → finalize response - └─ Incomplete → finalize partial response - ▼ - persist completed/incomplete state when required - ▼ - JSON or SSE - ▼ -Client -``` - -If an error occurs before streaming begins, the client receives an HTTP JSON error. If it occurs after SSE has begun, -the client receives an SSE error event followed by `[DONE]`. - -This is the complete logical round trip for the executor path. Individual database queries, specific tool executors, -event translation details, and WebSocket session mechanics are deeper subflows, but they do not change this top-level -lifecycle. - -## 14. Recommended reading order - -1. [`ARCHITECTURE.md`](ARCHITECTURE.md) — crate boundaries and the maintained architecture map. -2. [`TERMINOLOGY.md`](TERMINOLOGY.md) — normative project vocabulary. -3. [`crates/agentic-server/src/main.rs`](crates/agentic-server/src/main.rs#L307) — process startup. -4. [`crates/agentic-server/src/server.rs`](crates/agentic-server/src/server.rs#L35) — state construction and serving. -5. [`crates/agentic-server/src/app.rs`](crates/agentic-server/src/app.rs#L242) — all routes. -6. [`handler/http/responses.rs`](crates/agentic-server/src/handler/http/responses.rs#L49) — pass-through/executor fork. -7. [`executor/engine.rs`](crates/agentic-server-core/src/executor/engine.rs#L119) — orchestration and repeated inference. -8. [`executor/rehydrate.rs`](crates/agentic-server-core/src/executor/rehydrate.rs#L26) — item-history loading. -9. [`types/request_response.rs`](crates/agentic-server-core/src/types/request_response.rs#L108) — request, upstream, and response payloads. -10. [`executor/upstream.rs`](crates/agentic-server-core/src/executor/upstream.rs#L35) — blocking and streaming fetches. -11. [`executor/inference.rs`](crates/agentic-server-core/src/executor/inference.rs#L55) — actual upstream HTTP transport. -12. [`tool/registry.rs`](crates/agentic-server-core/src/tool/registry.rs#L159) and - [`tool/normalize.rs`](crates/agentic-server-core/src/tool/normalize.rs#L75) — routing versus normalization. -13. [`executor/persist.rs`](crates/agentic-server-core/src/executor/persist.rs#L21) — storage decision and write path. - -## 15. Useful run commands - -Run the gateway explicitly: - -```bash -cargo run -p agentic-server --bin agentic-server -- \ - --llm-api-base http://127.0.0.1:8000 -``` - -Inspect the launcher: - -```bash -cargo run -p agentic-server --bin agentic -- --help -``` - -## 16. Read-only inspection commands used during the walkthrough - -The walkthrough used repository search and source inspection; it did not change production code or send a request: - -```bash -git status --short - -rg --files -g 'Cargo.toml' -g '*.rs' -g 'ARCHITECTURE.md' \ - -g 'TERMINOLOGY.md' - -rg -n 'Router|\.route\(|fn main|ExecuteRequest|run_gateway_tool_loop' crates/ - -rg -n 'build_with_handlers|to_upstream_request|to_function_tools|normalize_sse_line' \ - crates/agentic-server-core/src -g '*.rs' - -rg -n 'enum LoopDecision|fn classify_round|execute_output_calls|append_tool_outputs' \ - crates/agentic-server-core/src/executor/gateway.rs - -rg -n 'MAX_GATEWAY_TOOL_ROUNDS' crates/agentic-server-core/src - -sed -n '…' -nl -ba - -cargo metadata --no-deps --format-version 1 -``` - -The only writes made for this documentation request are `round-trip.md` and `round-trip.html`. From 8fd35ea02b8f3d0d854bd35069732057777dcfe5 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Fri, 28 Aug 2026 09:36:02 +0000 Subject: [PATCH 11/11] Updates Signed-off-by: haoshan98 --- ARCHITECTURE.md | 9 +- .../agentic-server-core/src/events/types.rs | 3 + .../src/executor/accumulator.rs | 49 ++ .../src/executor/function_sse.rs | 776 +++++++++++++++++- .../src/executor/upstream.rs | 26 +- .../agentic-server-core/src/tool/registry.rs | 143 ++-- .../src/tool/tool_search.rs | 645 ++++----------- .../src/types/io/output.rs | 166 +--- 8 files changed, 1053 insertions(+), 764 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b1c736e1..15b6be40 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -329,9 +329,9 @@ via `process_event`/`synthetic_event`/`emit_sse_frame`. #### `function_sse.rs` — `FunctionSseTranslator` -vLLM only ever emits `function_call` SSE events, regardless of which tool type the -call is routed to. This translator looks up each call's name in the tool registry and -reshapes the raw stream accordingly: +Upstreams without native support for a declared tool type emit `function_call` SSE +events instead. This translator borrows the request-scoped tool registry for +classification and reshapes those raw calls accordingly: - **Custom tools** — rewritten into the public `custom_tool_call` event shape (`output_item.added` / `custom_tool_call_input.delta` / `.done` / `output_item.done`), reconstructing the `input` JSON incrementally from the streamed `arguments`. @@ -339,6 +339,9 @@ reshapes the raw stream accordingly: frames are suppressed entirely. Their real client-visible events are synthesized later, once the call has actually executed, by `gateway.rs`. - **Client-owned tools** (`Function`, `CodexNamespace`) — pass through unchanged. +- **Tool search** — native `tool_search_call` events pass through as typed items; + synthetic `function_call` events named `tool_search` are projected into that same + public lifecycle after validation. It also buffers function-call events that arrive before the call's name is known (bounded at 256 KiB) and replays them once the name resolves. diff --git a/crates/agentic-server-core/src/events/types.rs b/crates/agentic-server-core/src/events/types.rs index ae012736..25b6ea5e 100644 --- a/crates/agentic-server-core/src/events/types.rs +++ b/crates/agentic-server-core/src/events/types.rs @@ -8,6 +8,7 @@ use crate::types::io::ResponseUsage; pub enum SSEItemType { Reasoning, FunctionCall, + ToolSearchCall, CustomToolCall, WebSearchCall, McpCall, @@ -22,6 +23,7 @@ impl SSEItemType { match self { Self::Reasoning => "reasoning", Self::FunctionCall => "function_call", + Self::ToolSearchCall => "tool_search_call", Self::CustomToolCall => "custom_tool_call", Self::WebSearchCall => "web_search_call", Self::McpCall => "mcp_call", @@ -37,6 +39,7 @@ impl From<&str> for SSEItemType { match s { "reasoning" => Self::Reasoning, "function_call" => Self::FunctionCall, + "tool_search_call" => Self::ToolSearchCall, "custom_tool_call" => Self::CustomToolCall, "web_search_call" => Self::WebSearchCall, "mcp_call" => Self::McpCall, diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 166954d4..451b5252 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -34,6 +34,7 @@ enum InFlight { Message { item: OutputMessage, text: String }, Reasoning { item: ReasoningOutput, text: String }, FunctionCall { item: FunctionToolCall, arguments: String }, + ToolSearchCall { item: crate::types::io::ToolSearchCall }, CustomToolCall { item: CustomToolCall, input: String }, WebSearchCall { item: Option }, McpCall { item: McpCall }, @@ -47,6 +48,7 @@ impl std::fmt::Debug for InFlight { Self::Message { .. } => write!(f, "InFlight::Message {{ .. }}"), Self::Reasoning { .. } => write!(f, "InFlight::Reasoning {{ .. }}"), Self::FunctionCall { .. } => write!(f, "InFlight::FunctionCall {{ .. }}"), + Self::ToolSearchCall { .. } => write!(f, "InFlight::ToolSearchCall {{ .. }}"), Self::CustomToolCall { .. } => write!(f, "InFlight::CustomToolCall {{ .. }}"), Self::WebSearchCall { .. } => write!(f, "InFlight::WebSearchCall {{ .. }}"), Self::McpCall { .. } => write!(f, "InFlight::McpCall {{ .. }}"), @@ -72,6 +74,7 @@ impl InFlight { item.status = MessageStatus::Completed; Some(OutputItem::FunctionCall(item)) } + Self::ToolSearchCall { item } => Some(OutputItem::ToolSearchCall(item)), Self::Message { mut item, text } => { if !text.is_empty() { item.content.push(OutputTextContent::new(text)); @@ -469,6 +472,9 @@ impl ResponseAccumulator { item, arguments: String::with_capacity(128), }), + SSEItemType::ToolSearchCall => crate::types::io::ToolSearchCall::try_from(payload) + .ok() + .map(|item| InFlight::ToolSearchCall { item }), SSEItemType::CustomToolCall => { CustomToolCall::try_from(payload) .ok() @@ -535,6 +541,7 @@ impl ResponseAccumulator { 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::ToolSearchCall { item }, _) => item.apply_done(payload, &mut String::new()), (InFlight::CustomToolCall { item, input }, _) => item.apply_done(payload, input), (InFlight::McpCall { item }, _) => item.apply_done(payload, &mut String::new()), (InFlight::McpListTools { item }, _) => item.apply_done(payload, &mut String::new()), @@ -555,6 +562,7 @@ impl ResponseAccumulator { if let Some( mut output_item @ (OutputItem::FunctionCall(_) + | OutputItem::ToolSearchCall(_) | OutputItem::CustomToolCall(_) | OutputItem::WebSearchCall(_) | OutputItem::McpCall(_) @@ -628,6 +636,7 @@ fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool matches!( (item, item_type), (InFlight::FunctionCall { .. }, SSEItemType::FunctionCall) + | (InFlight::ToolSearchCall { .. }, SSEItemType::ToolSearchCall) | (InFlight::CustomToolCall { .. }, SSEItemType::CustomToolCall) | (InFlight::WebSearchCall { .. }, SSEItemType::WebSearchCall) | (InFlight::McpCall { .. }, SSEItemType::McpCall) @@ -1845,4 +1854,44 @@ mod tests { assert_eq!(call.name, "raw_echo"); assert_eq!(call.input, "hello"); } + + #[test] + fn native_tool_search_call_accumulates_from_added_and_done() { + let acc = ResponseAccumulator::from_sse_lines( + [ + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"tool_search_call","id":"tsc_native","call_id":"call_search","execution":"client","arguments":{},"status":"in_progress"}}"#.to_owned(), + r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"tool_search_call","id":"tsc_native","call_id":"call_search","execution":"client","arguments":{"query":"weather"},"status":"completed"}}"#.to_owned(), + r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#.to_owned(), + ], + None, + ); + + let [OutputItem::ToolSearchCall(call)] = acc.output.as_slice() else { + panic!("expected native tool_search_call"); + }; + assert_eq!(call.id, "tsc_native"); + assert_eq!(call.call_id, "call_search"); + assert_eq!(call.arguments["query"], "weather"); + assert_eq!(call.status, crate::types::tools::ToolSearchStatus::Completed); + } + + #[test] + fn blocking_native_tool_search_call_remains_typed() { + let body = serde_json::json!({ + "id": "resp_1", + "status": "completed", + "output": [{ + "type": "tool_search_call", + "id": "tsc_native", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + }] + }) + .to_string(); + + let acc = ResponseAccumulator::from_json(&body, None).expect("valid blocking response"); + assert!(matches!(acc.output.as_slice(), [OutputItem::ToolSearchCall(_)])); + } } diff --git a/crates/agentic-server-core/src/executor/function_sse.rs b/crates/agentic-server-core/src/executor/function_sse.rs index 94fecd04..d7d47229 100644 --- a/crates/agentic-server-core/src/executor/function_sse.rs +++ b/crates/agentic-server-core/src/executor/function_sse.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::Value; @@ -6,8 +6,9 @@ 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; +use crate::tool::{ToolRegistry, ToolType, tool_search}; +use crate::types::io::OutputItem; +use crate::utils::common::{serialize_to_string, serialize_to_value}; const MAX_PENDING_FUNCTION_BYTES: usize = 256 * 1024; @@ -16,6 +17,14 @@ enum FunctionCallShape { PublicFunction, GatewayOwned, Custom(CustomCallState), + ToolSearch { internal_item_id: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamTerminalState { + Open, + Completed, + Aborted, } #[derive(Debug)] @@ -31,6 +40,7 @@ struct CustomCallState { #[derive(Debug, Default)] struct PendingFunctionCall { output_index: u32, + internal_item_id: Option, frames: Vec, bytes: usize, } @@ -41,23 +51,35 @@ pub(super) struct FunctionSseTranslation { pub(super) defer_from_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, +pub(super) struct FunctionSseOutcome { + pub(super) unfinished_tool_search_item_ids: HashSet, +} + +/// Restores normalized upstream function-call SSE to the public call shape. +/// Tool routing remains in the request-scoped registry; this type borrows its +/// classification facts while owning only per-stream lifecycle state. +#[derive(Debug)] +pub(super) struct FunctionSseTranslator<'a> { + registry: &'a ToolRegistry, active: HashMap, pending_unnamed: HashMap, pending_bytes: usize, first_gateway_output_index: Option, + active_native_tool_search: HashSet, + terminal: StreamTerminalState, } -impl FunctionSseTranslator { - pub(super) fn new(tool_types: HashMap) -> Self { +impl<'a> FunctionSseTranslator<'a> { + pub(super) fn new(registry: &'a ToolRegistry) -> Self { Self { - tool_types, - ..Self::default() + registry, + active: HashMap::new(), + pending_unnamed: HashMap::new(), + pending_bytes: 0, + first_gateway_output_index: None, + active_native_tool_search: HashSet::new(), + terminal: StreamTerminalState::Open, } } @@ -66,6 +88,13 @@ impl FunctionSseTranslator { frame: EventFrame, call: Option>, ) -> ExecutorResult { + self.validate_frame(&frame)?; + self.track_native_tool_search(&frame)?; + self.terminal = match frame.event_type { + SSEEventType::ResponseCompleted => StreamTerminalState::Completed, + SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete => StreamTerminalState::Aborted, + _ => self.terminal, + }; let mut translated = match &frame.payload { EventPayload::OutputItemAdded { item_id, @@ -75,12 +104,15 @@ impl FunctionSseTranslator { .. } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call), EventPayload::OutputItemAdded { - item_id: _, + item_id, item_type: SSEItemType::FunctionCall, output_index, name: None, .. - } => self.buffer_unnamed(*output_index, frame), + } => { + let item_id = item_id.clone(); + self.buffer_unnamed(&item_id, *output_index, frame, call) + } EventPayload::FunctionCallArgsDelta { item_id, output_index, .. } => self.translate_delta(item_id, *output_index, frame.clone(), call), @@ -108,6 +140,18 @@ impl FunctionSseTranslator { Ok(translated) } + pub(super) fn finish(self) -> ExecutorResult { + let unfinished_tool_search_item_ids = self.unfinished_tool_search_item_ids(); + if self.terminal != StreamTerminalState::Aborted + && (!unfinished_tool_search_item_ids.is_empty() || !self.active_native_tool_search.is_empty()) + { + return Err(tool_search::invalid_upstream_search_call().into()); + } + Ok(FunctionSseOutcome { + unfinished_tool_search_item_ids, + }) + } + fn start_call( &mut self, item_id: &str, @@ -116,7 +160,13 @@ impl FunctionSseTranslator { original: Option, call: Option>, ) -> ExecutorResult { - match self.tool_type(name) { + let tool_type = self.registry.tool_type(name); + if tool_type == ToolType::ToolSearch + && let Some(original) = original.as_ref() + { + validate_tool_search_added(original, name)?; + } + match tool_type { ToolType::Custom => { let public_item_id = call.as_ref().map_or_else( || crate::tool::custom::public_item_id(item_id), @@ -149,7 +199,21 @@ impl FunctionSseTranslator { self.active.insert(output_index, FunctionCallShape::GatewayOwned); Ok(FunctionSseTranslation::default()) } - ToolType::Function | ToolType::ToolSearch | ToolType::CodexNamespace => { + ToolType::ToolSearch => { + let call = call.ok_or_else(|| ExecutorError::Tool(tool_search::invalid_upstream_search_call()))?; + let public = tool_search::started_public_call(call.item)?; + self.active.insert( + output_index, + FunctionCallShape::ToolSearch { + internal_item_id: call.item.id.clone(), + }, + ); + Ok(FunctionSseTranslation { + frames: vec![tool_search_frame(SSEEventType::OutputItemAdded, output_index, &public)?], + defer_from_output_index: None, + }) + } + ToolType::Function | ToolType::CodexNamespace => { self.active.insert(output_index, FunctionCallShape::PublicFunction); Ok(FunctionSseTranslation { frames: original.into_iter().collect(), @@ -161,7 +225,7 @@ impl FunctionSseTranslator { fn translate_delta( &mut self, - _item_id: &str, + item_id: &str, output_index: u32, original: EventFrame, call: Option>, @@ -182,7 +246,13 @@ impl FunctionSseTranslator { defer_from_output_index: None, }) } - None => self.buffer_unnamed(output_index, original), + Some(FunctionCallShape::ToolSearch { .. }) => { + if let Some(call) = call { + ensure_function_call_size(call.arguments())?; + } + Ok(FunctionSseTranslation::default()) + } + None => self.buffer_unnamed(item_id, output_index, original, call), } } @@ -203,6 +273,11 @@ impl FunctionSseTranslator { translated.frames.extend(finish_custom_input(state, call.arguments())?); } } + Some(FunctionCallShape::ToolSearch { .. }) => { + let call = call.ok_or_else(|| ExecutorError::Tool(tool_search::invalid_upstream_search_call()))?; + ensure_function_call_size(call.arguments())?; + tool_search::validate_public_arguments(call.arguments())?; + } } Ok(translated) } @@ -227,6 +302,18 @@ impl FunctionSseTranslator { translated.frames.push(custom_done_frame(&state, &call)?); } } + Some(shape @ FunctionCallShape::ToolSearch { .. }) => { + let call = call.ok_or_else(|| ExecutorError::Tool(tool_search::invalid_upstream_search_call()))?; + ensure_function_call_size(call.arguments())?; + if call.item.status == crate::types::event::MessageStatus::Completed { + let public = tool_search::completed_public_call(call.item)?; + translated + .frames + .push(tool_search_frame(SSEEventType::OutputItemDone, output_index, &public)?); + } else { + self.active.insert(output_index, shape); + } + } } Ok(translated) } @@ -263,8 +350,131 @@ impl FunctionSseTranslator { Ok(translated) } - fn tool_type(&self, name: &str) -> ToolType { - self.tool_types.get(name).copied().unwrap_or(ToolType::Function) + fn validate_frame(&self, frame: &EventFrame) -> ExecutorResult<()> { + let lifecycle_name = match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + name: Some(name), + .. + } + | EventPayload::FunctionCallArgsDone { name, .. } => Some(name.as_str()), + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + item, + .. + } => item.get("name").and_then(Value::as_str), + _ => None, + }; + if let Some(name) = lifecycle_name { + tool_search::ensure_function_is_available(self.registry.is_withheld_function(name))?; + } + + match &frame.payload { + EventPayload::OutputItemDone { + item_type: SSEItemType::FunctionCall, + item, + .. + } if item + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| self.registry.tool_type(name) == ToolType::ToolSearch) => + { + tool_search::strict_function_call(item)?; + } + EventPayload::Response { .. } + if matches!( + frame.event_type, + SSEEventType::ResponseCompleted | SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete + ) => + { + self.validate_terminal_output(frame)?; + } + _ => {} + } + Ok(()) + } + + fn validate_terminal_output(&self, frame: &EventFrame) -> ExecutorResult<()> { + let Some(output) = frame + .wire + .rest + .get("response") + .and_then(|response| response.get("output")) + .and_then(Value::as_array) + else { + return Ok(()); + }; + let completed = frame.event_type == SSEEventType::ResponseCompleted; + for item in output { + match item.get("type").and_then(Value::as_str) { + Some("function_call") => { + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + tool_search::ensure_function_is_available(self.registry.is_withheld_function(name))?; + if self.registry.tool_type(name) == ToolType::ToolSearch { + let call = tool_search::strict_function_call(item)?; + ensure_function_call_size(&call.arguments)?; + if completed && call.status != crate::types::event::MessageStatus::Completed { + return Err(tool_search::invalid_upstream_search_call().into()); + } + } + } + Some("tool_search_call") => { + let call = tool_search::strict_native_call(item.clone())?; + let arguments = serialize_to_string(&call.arguments).map_err(ExecutorError::JsonError)?; + ensure_function_call_size(&arguments)?; + if completed && call.status != crate::types::tools::ToolSearchStatus::Completed { + return Err(tool_search::invalid_upstream_search_call().into()); + } + } + _ => {} + } + } + Ok(()) + } + + fn track_native_tool_search(&mut self, frame: &EventFrame) -> ExecutorResult<()> { + match &frame.payload { + EventPayload::OutputItemAdded { + item_type: SSEItemType::ToolSearchCall, + output_index, + .. + } => { + validate_native_tool_search_frame(frame)?; + self.active_native_tool_search.insert(*output_index); + } + EventPayload::OutputItemDone { + item_type: SSEItemType::ToolSearchCall, + output_index, + .. + } => { + let call = validate_native_tool_search_frame(frame)?; + if call.status == crate::types::tools::ToolSearchStatus::Completed { + self.active_native_tool_search.remove(output_index); + } else { + self.active_native_tool_search.insert(*output_index); + } + } + _ => {} + } + Ok(()) + } + + fn unfinished_tool_search_item_ids(&self) -> HashSet { + let active = self.active.values().filter_map(|shape| match shape { + FunctionCallShape::ToolSearch { internal_item_id } => Some(internal_item_id.clone()), + FunctionCallShape::PublicFunction | FunctionCallShape::GatewayOwned | FunctionCallShape::Custom(_) => None, + }); + let pending = self + .registry + .tool_search_is_active() + .then(|| { + self.pending_unnamed + .values() + .filter_map(|pending| pending.internal_item_id.clone()) + }) + .into_iter() + .flatten(); + active.chain(pending).collect() } fn defer_from_output_index(&self) -> Option { @@ -274,7 +484,13 @@ impl FunctionSseTranslator { .min() } - fn buffer_unnamed(&mut self, output_index: u32, frame: EventFrame) -> ExecutorResult { + fn buffer_unnamed( + &mut self, + item_id: &str, + output_index: u32, + frame: EventFrame, + call: Option>, + ) -> ExecutorResult { let bytes = serialize_to_string(&frame.wire) .map_err(ExecutorError::JsonError)? .len(); @@ -290,6 +506,14 @@ impl FunctionSseTranslator { output_index, ..PendingFunctionCall::default() }); + if let Some(internal_item_id) = call + .map(|call| call.item.id.as_str()) + .filter(|item_id| !item_id.is_empty()) + { + pending.internal_item_id = Some(internal_item_id.to_owned()); + } else if pending.internal_item_id.is_none() && !item_id.is_empty() { + pending.internal_item_id = Some(item_id.to_owned()); + } pending.frames.push(frame); pending.bytes = pending.bytes.saturating_add(bytes); self.pending_bytes = self.pending_bytes.saturating_add(bytes); @@ -305,6 +529,53 @@ impl FunctionSseTranslator { } } +fn validate_tool_search_added(frame: &EventFrame, name: &str) -> ExecutorResult<()> { + let Some(item) = frame.wire.rest.get("item") else { + return Err(tool_search::invalid_upstream_search_call().into()); + }; + let mut item = item.clone(); + match item.get("name") { + None | Some(Value::Null) => { + item.as_object_mut() + .ok_or_else(tool_search::invalid_upstream_search_call)? + .insert("name".to_owned(), Value::String(name.to_owned())); + } + Some(Value::String(_)) => {} + Some(_) => return Err(tool_search::invalid_upstream_search_call().into()), + } + tool_search::strict_started_function(&item)?; + Ok(()) +} + +fn validate_native_tool_search_frame(frame: &EventFrame) -> ExecutorResult { + let item = frame + .wire + .rest + .get("item") + .cloned() + .ok_or_else(tool_search::invalid_upstream_search_call)?; + let call = tool_search::strict_native_call(item)?; + let arguments = serialize_to_string(&call.arguments).map_err(ExecutorError::JsonError)?; + ensure_function_call_size(&arguments)?; + if frame.event_type == SSEEventType::OutputItemAdded + && (call.status != crate::types::tools::ToolSearchStatus::InProgress || !call.arguments.is_empty()) + { + return Err(tool_search::invalid_upstream_search_call().into()); + } + Ok(call) +} + +fn tool_search_frame( + event_type: SSEEventType, + output_index: u32, + call: &crate::types::io::ToolSearchCall, +) -> ExecutorResult { + let item = serialize_to_value(&OutputItem::ToolSearchCall(call.clone())).map_err(ExecutorError::JsonError)?; + let mut frame = synthetic_event(event_type, [("item".to_owned(), item)])?; + frame.wire.output_index = Some(u64::from(output_index)); + Ok(frame) +} + fn custom_added_frame(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult { custom_frame( SSEEventType::OutputItemAdded, @@ -513,6 +784,10 @@ mod tests { use super::*; use crate::executor::accumulator::ResponseAccumulator; + fn test_registry(tool_types: HashMap) -> ToolRegistry { + ToolRegistry::from_tool_types(tool_types) + } + fn sse(value: &Value) -> String { format!("data: {value}") } @@ -531,7 +806,8 @@ mod tests { #[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 registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let mut frames = Vec::new(); for event in [ @@ -612,7 +888,8 @@ mod tests { #[test] fn custom_input_deltas_match_authoritative_done_input() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -656,7 +933,8 @@ mod tests { #[test] fn custom_input_rejects_authoritative_value_that_contradicts_deltas() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -686,7 +964,8 @@ mod tests { #[test] fn malformed_custom_input_escape_is_rejected() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let added = serde_json::json!({ "type": "response.output_item.added", "output_index": 0, "item": {"id": "fc_1", "type": "function_call", "call_id": "call_1", @@ -707,7 +986,8 @@ mod tests { #[test] fn custom_input_waits_for_split_unicode_surrogate_pair() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -740,7 +1020,8 @@ mod tests { #[test] fn custom_input_over_limit_is_rejected() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let added = serde_json::json!({ "type": "response.output_item.added", "output_index": 0, "item": {"id": "fc_1", "type": "function_call", "call_id": "call_1", @@ -762,7 +1043,8 @@ mod tests { #[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 registry = test_registry(HashMap::from([("echo".to_owned(), ToolType::Function)])); + let mut translator = FunctionSseTranslator::new(®istry); let event = serde_json::json!({ "type": "response.output_item.added", "output_index": 3, @@ -786,7 +1068,8 @@ mod tests { #[test] fn unnamed_function_frames_are_recovered_by_output_index_when_done_changes_id() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("echo".to_owned(), ToolType::Function)])); + let registry = test_registry(HashMap::from([("echo".to_owned(), ToolType::Function)])); + let mut translator = FunctionSseTranslator::new(®istry); let mut frames = Vec::new(); let mut defer_boundaries = Vec::new(); @@ -844,10 +1127,11 @@ mod tests { #[test] fn parallel_unnamed_functions_with_empty_ids_remain_distinct() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([ + let registry = test_registry(HashMap::from([ ("first".to_owned(), ToolType::Function), ("second".to_owned(), ToolType::Function), ])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -901,10 +1185,11 @@ mod tests { #[test] fn parallel_named_custom_functions_with_empty_ids_remain_distinct() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([ + let registry = test_registry(HashMap::from([ ("first".to_owned(), ToolType::Custom), ("second".to_owned(), ToolType::Custom), ])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -942,7 +1227,8 @@ mod tests { #[test] fn unnamed_custom_function_with_empty_id_uses_one_public_id() { let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); - let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let registry = test_registry(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut translator = FunctionSseTranslator::new(®istry); let events = [ serde_json::json!({ "type": "response.output_item.added", "output_index": 0, @@ -983,8 +1269,8 @@ mod tests { #[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 registry = test_registry(HashMap::from([("web_search".to_owned(), ToolType::WebSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); let added = serde_json::json!({ "type": "response.output_item.added", "output_index": 2, @@ -1012,4 +1298,426 @@ mod tests { assert!(delta.frames.is_empty()); assert_eq!(delta.defer_from_output_index, Some(2)); } + + #[test] + fn synthetic_tool_search_emits_public_frames_but_accumulates_function_call() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); + let events = [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_search", + "type": "function_call", + "call_id": "call_search", + "name": "tool_search", + "arguments": "", + "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_search", + "call_id": "call_search", + "delta": "{\"query\":\"weather\"}" + }), + serde_json::json!({ + "type": "response.function_call_arguments.done", + "output_index": 0, + "item_id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}" + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "fc_search", + "type": "function_call", + "call_id": "call_search", + "name": "tool_search", + "arguments": "{\"query\":\"weather\"}", + "status": "completed" + } + }), + serde_json::json!({ + "type": "response.completed", + "response": {"id": "resp_1", "status": "completed", "output": []} + }), + ]; + + let frames = events + .iter() + .flat_map(|event| translate(&mut accumulator, &mut translator, event).frames) + .collect::>(); + let outcome = translator.finish().expect("completed lifecycle"); + assert!(outcome.unfinished_tool_search_item_ids.is_empty()); + assert_eq!( + frames + .iter() + .filter(|frame| { + matches!( + frame.event_type, + SSEEventType::OutputItemAdded | SSEEventType::OutputItemDone + ) && frame.wire.rest["item"]["type"] == "tool_search_call" + }) + .count(), + 2 + ); + assert!( + frames + .iter() + .all(|frame| !matches!(frame.event_type, SSEEventType::FunctionCallArgumentsDelta)) + ); + + let payload = accumulator.finalize("test", None, None); + assert!(matches!(payload.output.as_slice(), [OutputItem::FunctionCall(_)])); + } + + #[test] + fn synthetic_tool_search_rejects_non_string_name_in_buffered_added_item() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); + translate( + &mut accumulator, + &mut translator, + &serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": 7, "arguments": "", "status": "in_progress" + } + }), + ); + let arguments_done = serde_json::json!({ + "type": "response.function_call_arguments.done", + "output_index": 0, + "item_id": "fc_search", + "call_id": "call_search", + "name": "tool_search", + "arguments": "{}" + }); + + let error = accumulator + .process_sse_line_with_translator(&sse(&arguments_done), &mut translator) + .expect_err("a malformed buffered name must not be overwritten"); + assert!(matches!( + error, + ExecutorError::Tool(crate::tool::ToolError::InvalidUpstreamToolSearch) + )); + } + + #[test] + fn native_tool_search_frames_pass_through_and_accumulate_natively() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::new()); + let mut translator = FunctionSseTranslator::new(®istry); + let events = [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "tsc_native", + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "arguments": {}, + "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "tsc_native", + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "weather"}, + "status": "completed" + } + }), + serde_json::json!({ + "type": "response.completed", + "response": {"id": "resp_1", "status": "completed", "output": []} + }), + ]; + + let frames = events + .iter() + .flat_map(|event| translate(&mut accumulator, &mut translator, event).frames) + .collect::>(); + translator.finish().expect("completed native lifecycle"); + assert_eq!(frames[0].wire.rest["item"]["type"], "tool_search_call"); + assert_eq!(frames[1].wire.rest["item"]["type"], "tool_search_call"); + + let payload = accumulator.finalize("test", None, None); + let [OutputItem::ToolSearchCall(call)] = payload.output.as_slice() else { + panic!("native tool_search_call must remain typed"); + }; + assert_eq!(call.arguments["query"], "weather"); + } + + #[test] + fn oversized_native_tool_search_done_arguments_are_rejected() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::new()); + let mut translator = FunctionSseTranslator::new(®istry); + let done = serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "tsc_native", + "type": "tool_search_call", + "call_id": "call_search", + "execution": "client", + "arguments": {"query": "x".repeat(MAX_PENDING_FUNCTION_BYTES)}, + "status": "completed" + } + }); + + let error = accumulator + .process_sse_line_with_translator(&sse(&done), &mut translator) + .expect_err("oversized native arguments must fail"); + assert!(error.to_string().contains("function-call SSE exceeded")); + } + + #[test] + fn oversized_synthetic_tool_search_done_arguments_are_rejected() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); + let done = serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "fc_search", + "type": "function_call", + "call_id": "call_search", + "name": "tool_search", + "arguments": format!("{{\"query\":\"{}\"}}", "x".repeat(MAX_PENDING_FUNCTION_BYTES)), + "status": "completed" + } + }); + + let error = accumulator + .process_sse_line_with_translator(&sse(&done), &mut translator) + .expect_err("oversized synthetic arguments must fail"); + assert!(error.to_string().contains("function-call SSE exceeded")); + } + + #[test] + fn successful_eof_rejects_unfinished_synthetic_and_native_searches() { + let synthetic_registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut synthetic_accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut synthetic = FunctionSseTranslator::new(&synthetic_registry); + translate( + &mut synthetic_accumulator, + &mut synthetic, + &serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": "", "status": "in_progress" + } + }), + ); + assert!(synthetic.finish().is_err()); + + let native_registry = test_registry(HashMap::new()); + let mut native_accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut native = FunctionSseTranslator::new(&native_registry); + translate( + &mut native_accumulator, + &mut native, + &serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "tsc_native", "type": "tool_search_call", "call_id": "call_search", + "execution": "client", "arguments": {}, "status": "in_progress" + } + }), + ); + assert!(native.finish().is_err()); + } + + #[test] + fn aborted_stream_reports_and_discards_unfinished_synthetic_search() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); + for event in [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": "", "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "fc_search", "type": "function_call", "call_id": "call_search", + "name": "tool_search", "arguments": "{\"query\":", "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.incomplete", + "response": {"id": "resp_1", "status": "incomplete", "output": []} + }), + ] { + translate(&mut accumulator, &mut translator, &event); + } + + let outcome = translator.finish().expect("aborted lifecycle may remain unfinished"); + assert_eq!( + outcome.unfinished_tool_search_item_ids, + HashSet::from(["fc_search".to_owned()]) + ); + let mut payload = accumulator.finalize("test", None, None); + registry + .normalize_response_output( + &mut payload.output, + crate::types::event::ResponseStatus::Incomplete, + &outcome.unfinished_tool_search_item_ids, + ) + .expect("unfinished synthetic call is discarded"); + assert!(payload.output.is_empty()); + } + + #[test] + fn aborted_stream_discards_unnamed_search_candidate_with_empty_raw_id() { + for (terminal_event, terminal_status, response_status) in [ + ( + "response.incomplete", + "incomplete", + crate::types::event::ResponseStatus::Incomplete, + ), + ("response.failed", "failed", crate::types::event::ResponseStatus::Error), + ] { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("tool_search".to_owned(), ToolType::ToolSearch)])); + let mut translator = FunctionSseTranslator::new(®istry); + for event in [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "", "type": "function_call", "call_id": "call_search", + "arguments": "", "status": "in_progress" + } + }), + serde_json::json!({ + "type": terminal_event, + "response": {"id": "resp_1", "status": terminal_status, "output": []} + }), + ] { + translate(&mut accumulator, &mut translator, &event); + } + + let outcome = translator + .finish() + .expect("aborted lifecycle may leave the call unnamed"); + assert_eq!(outcome.unfinished_tool_search_item_ids.len(), 1); + let internal_item_id = outcome + .unfinished_tool_search_item_ids + .iter() + .next() + .expect("accumulator-generated item id"); + assert!(internal_item_id.starts_with("fc_")); + + let mut payload = accumulator.finalize("test", None, None); + let [OutputItem::FunctionCall(call)] = payload.output.as_slice() else { + panic!("unfinished unnamed call must be accumulated as a function call"); + }; + assert!(call.name.is_empty()); + assert_eq!(&call.id, internal_item_id); + registry + .normalize_response_output( + &mut payload.output, + response_status, + &outcome.unfinished_tool_search_item_ids, + ) + .expect("unfinished unnamed search candidate is discarded"); + assert!(payload.output.is_empty()); + } + } + + #[test] + fn aborted_stream_discards_unfinished_native_search() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::new()); + let mut translator = FunctionSseTranslator::new(®istry); + for event in [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "tsc_native", "type": "tool_search_call", "call_id": "call_search", + "execution": "client", "arguments": {}, "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "tsc_native", "type": "tool_search_call", "call_id": "call_search", + "execution": "client", "arguments": {}, "status": "incomplete" + } + }), + serde_json::json!({ + "type": "response.failed", + "response": {"id": "resp_1", "status": "failed", "output": []} + }), + ] { + translate(&mut accumulator, &mut translator, &event); + } + + let outcome = translator + .finish() + .expect("aborted native lifecycle may remain unfinished"); + assert!(outcome.unfinished_tool_search_item_ids.is_empty()); + let mut payload = accumulator.finalize("test", None, None); + registry + .normalize_response_output( + &mut payload.output, + crate::types::event::ResponseStatus::Error, + &outcome.unfinished_tool_search_item_ids, + ) + .expect("unfinished native call is discarded"); + assert!(payload.output.is_empty()); + } + + #[test] + fn unresolved_ordinary_function_does_not_become_tool_search_failure() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let registry = test_registry(HashMap::from([("echo".to_owned(), ToolType::Function)])); + let mut translator = FunctionSseTranslator::new(®istry); + translate( + &mut accumulator, + &mut translator, + &serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_ordinary", "type": "function_call", "call_id": "call_ordinary", + "arguments": "", "status": "in_progress" + } + }), + ); + + translator + .finish() + .expect("ordinary unnamed call is not a search candidate"); + } } diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index e851ecc9..64e72bd2 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -53,7 +53,7 @@ pub(super) async fn fetch_blocking_payload( ctx.original_request.instructions.as_deref(), ); let status = payload.status.parse().unwrap_or_default(); - registry.normalize_response_output(&mut payload.output, status)?; + registry.normalize_response_output(&mut payload.output, status, &std::collections::HashSet::new())?; ctx.inject_ids(&mut payload); Ok(payload) @@ -81,26 +81,14 @@ pub(super) async fn fetch_stream_payload( auth.map(str::to_owned), exec_ctx.streaming_timeout, )); - let tool_types = registry.tool_type_map(); let mut acc = ResponseAccumulator::new(ctx.response_id.clone(), ctx.conversation_id.clone()); - let mut function_sse = FunctionSseTranslator::new(tool_types); - registry.begin_stream_response(); + let mut function_sse = FunctionSseTranslator::new(registry); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); let mut deferred_bytes = 0; while let Some(line_result) = line_stream.next().await { let line = line_result?; - let adapted_line = registry.prepare_stream_line(&line)?; - let line = adapted_line.as_deref().unwrap_or(&line); - 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)? { - let mut translation = translation; - translation.frames = registry.translate_stream_frames(translation.frames)?; + if let Some(translation) = acc.process_sse_line_with_translator(&line, &mut function_sse)? { let previous_defer_from_output_index = defer_from_output_index; defer_from_output_index = translation.defer_from_output_index.map(u64::from); for frame in &translation.frames { @@ -140,7 +128,7 @@ pub(super) async fn fetch_stream_payload( } } } - registry.finish_stream_response()?; + let function_sse_outcome = function_sse.finish()?; acc.finish_stream(); let mut payload = acc.finalize( &ctx.enriched_request.model, @@ -148,7 +136,11 @@ pub(super) async fn fetch_stream_payload( ctx.original_request.instructions.as_deref(), ); let status = payload.status.parse().unwrap_or_default(); - registry.normalize_response_output(&mut payload.output, status)?; + registry.normalize_response_output( + &mut payload.output, + status, + &function_sse_outcome.unfinished_tool_search_item_ids, + )?; ctx.inject_ids(&mut payload); Ok(StreamPayload { payload, diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 1aa7e9ce..573a8932 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -12,18 +12,17 @@ use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; use super::mcp::registry::insert_discovered_mcp_entry; use super::tool_search::{ - TOOL_SEARCH_NAME, ToolSearchStreamState, ensure_request_prepared, insert_tool_search_entry, - validate_blocking_response, + TOOL_SEARCH_NAME, ensure_request_prepared, insert_tool_search_entry, validate_blocking_response, }; use super::web_search::insert_web_search_entry; use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput, ToolSearchState}; use crate::events::WireEvent; -use crate::types::event::{MessageStatus, ResponseStatus}; +use crate::types::event::ResponseStatus; use crate::types::io::OutputItem; use crate::types::io::output::{FunctionToolCall, McpListTools}; use crate::types::request_response::RequestPayload; -use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool, ToolSearchStatus}; +use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool}; use crate::utils::common::{serialize_to_value, serialize_to_value_or_custom_default}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -176,9 +175,6 @@ pub struct ToolRegistry { /// Prepared public/private tool-search projection for this request. tool_search: Option>, - /// Per-round response adaptation for synthetic and native search calls. - tool_search_stream: Box, - /// Built once from the declared tools, so final payload and streaming event /// restoration don't rebuild it on every call. namespace_map: Option, @@ -301,7 +297,6 @@ impl ToolRegistry { Ok(Self { entries, tool_search: None, - tool_search_stream: Box::default(), namespace_map, custom_tool_map, mcp_tool_map, @@ -387,75 +382,35 @@ impl ToolRegistry { ensure_request_prepared(request, self.tool_search.is_some()) } - pub(crate) fn begin_stream_response(&mut self) { - self.tool_search_stream.reset(); - } - - pub(crate) fn prepare_stream_line(&mut self, line: &str) -> Result, ToolError> { - let empty = HashSet::new(); - let state = self.tool_search.as_deref(); - self.tool_search_stream.prepare_line( - line, - state.is_some_and(ToolSearchState::is_active), - state.map_or(&empty, ToolSearchState::withheld_function_names), - ) - } - - pub(crate) fn translate_stream_frames( - &mut self, - frames: Vec, - ) -> Result, ToolError> { - self.tool_search_stream.translate_frames(frames) - } - - pub(crate) fn finish_stream_response(&self) -> Result<(), ToolError> { - self.tool_search_stream.finish() - } - pub(crate) fn normalize_response_output( &self, output: &mut Vec, status: ResponseStatus, + unfinished_stream_item_ids: &HashSet, ) -> Result<(), ToolError> { let discard_unfinished = matches!(status, ResponseStatus::Error | ResponseStatus::Incomplete); let mut normalized = Vec::with_capacity(output.len()); for item in std::mem::take(output) { match item { OutputItem::FunctionCall(call) - if discard_unfinished && self.tool_search_stream.unfinished_item_ids().contains(&call.id) => {} - OutputItem::FunctionCall(call) - if self - .tool_search - .as_ref() - .is_some_and(|state| state.withheld_function_names().contains(&call.name)) => - { - return Err(super::tool_search::invalid_upstream_withheld_function_call()); - } - OutputItem::FunctionCall(call) - if call.name == TOOL_SEARCH_NAME - && (self.tool_search_stream.canonical_call(&call.id).is_some() - || (self.tool_search.as_deref().is_some_and(ToolSearchState::is_active) - && self - .entries - .get(TOOL_SEARCH_NAME) - .is_none_or(|entry| entry.tool_type == ToolType::ToolSearch))) => - { - if call.status != MessageStatus::Completed { - if discard_unfinished { - continue; + if discard_unfinished && unfinished_stream_item_ids.contains(&call.id) => {} + OutputItem::FunctionCall(call) => { + super::tool_search::ensure_function_is_available(self.is_withheld_function(&call.name))?; + if self.tool_type(&call.name) == ToolType::ToolSearch { + if let Some(public) = super::tool_search::project_synthetic_call( + &call, + discard_unfinished, + unfinished_stream_item_ids.contains(&call.id), + )? { + normalized.push(OutputItem::ToolSearchCall(public)); } - return Err(super::tool_search::invalid_upstream_search_call()); + } else { + normalized.push(OutputItem::FunctionCall(call)); } - let public = self - .tool_search_stream - .canonical_call(&call.id) - .cloned() - .map_or_else(|| crate::types::io::ToolSearchCall::try_from(&call), Ok)?; - normalized.push(OutputItem::ToolSearchCall(public)); } - OutputItem::ToolSearchCall(call) if call.status != ToolSearchStatus::Completed => { - if !discard_unfinished { - return Err(super::tool_search::invalid_upstream_search_call()); + OutputItem::ToolSearchCall(call) => { + if let Some(public) = super::tool_search::project_native_call(&call, discard_unfinished)? { + normalized.push(OutputItem::ToolSearchCall(public)); } } item => normalized.push(item), @@ -492,18 +447,45 @@ impl ToolRegistry { self.entries.get(tool_name) } - pub(crate) fn tool_type_map(&self) -> HashMap { - let mut tool_types = self - .entries - .iter() - .map(|(name, entry)| (name.clone(), entry.tool_type)) - .collect::>(); - if self.tool_search.as_deref().is_some_and(ToolSearchState::is_active) { - tool_types - .entry(TOOL_SEARCH_NAME.to_owned()) - .or_insert(ToolType::ToolSearch); + pub(crate) fn tool_type(&self, name: &str) -> ToolType { + if name == TOOL_SEARCH_NAME && self.tool_search.as_deref().is_some_and(ToolSearchState::is_active) { + return ToolType::ToolSearch; + } + self.entries + .get(name) + .map_or(ToolType::Function, |entry| entry.tool_type) + } + + pub(crate) fn is_withheld_function(&self, name: &str) -> bool { + self.tool_search + .as_deref() + .is_some_and(|state| state.withheld_function_names().contains(name)) + } + + pub(crate) fn tool_search_is_active(&self) -> bool { + self.tool_type(TOOL_SEARCH_NAME) == ToolType::ToolSearch + } + + #[cfg(test)] + pub(crate) fn from_tool_types(tool_types: HashMap) -> Self { + let entries = tool_types + .into_iter() + .map(|(name, tool_type)| { + ( + name, + ToolEntry { + tool_type, + config: Value::Null, + server_label: None, + handler: None, + }, + ) + }) + .collect(); + Self { + entries, + ..Self::default() } - tool_types } #[must_use] @@ -620,6 +602,7 @@ mod tests { use super::*; use crate::tool::executors::GatewayExecutorRegistration; use crate::tool::mcp::{McpDiscoveredHandler, McpHandler}; + use crate::tool::tool_search; use crate::types::event::MessageStatus; use crate::types::tools::McpDiscoveredToolParam; use crate::utils::common::serialize_to_value; @@ -716,7 +699,7 @@ mod tests { "tool search has no gateway handler" ); - let output = OutputItem::ToolSearchCall(crate::types::io::ToolSearchCall::try_from(&call).unwrap()); + let output = OutputItem::ToolSearchCall(tool_search::completed_public_call(&call).unwrap()); assert_eq!( serialize_to_value(&output).unwrap(), serde_json::json!({ @@ -750,15 +733,15 @@ mod tests { "status": "completed" })) .unwrap(); - assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::ToolSearch)); - assert!(crate::types::io::ToolSearchCall::try_from(&valid).is_ok()); + assert_eq!(registry.tool_type("tool_search"), ToolType::ToolSearch); + assert!(tool_search::completed_public_call(&valid).is_ok()); let malformed: FunctionToolCall = serde_json::from_value(serde_json::json!({ "type": "function_call", "id": "fc_search", "call_id": "call_search", "name": "tool_search", "namespace": null, "arguments": "{}", "status": "in_progress" })) .unwrap(); - assert!(crate::types::io::ToolSearchCall::try_from(&malformed).is_err()); + assert!(tool_search::completed_public_call(&malformed).is_err()); } #[tokio::test] @@ -772,7 +755,7 @@ mod tests { let registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) .await .unwrap(); - assert_eq!(registry.tool_type_map().get("tool_search"), Some(&ToolType::Function)); + assert_eq!(registry.tool_type("tool_search"), ToolType::Function); } #[tokio::test] diff --git a/crates/agentic-server-core/src/tool/tool_search.rs b/crates/agentic-server-core/src/tool/tool_search.rs index 32775fe3..79fa7945 100644 --- a/crates/agentic-server-core/src/tool/tool_search.rs +++ b/crates/agentic-server-core/src/tool/tool_search.rs @@ -1,12 +1,11 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType, normalize_sse_line}; use crate::types::event::{MessageStatus, ResponseStatus}; -use crate::types::io::output::{BlockingFunctionToolCall, FunctionToolCall, OutputItem}; +use crate::types::io::output::FunctionToolCall; use crate::types::io::{ FunctionTool, FunctionToolResultMessage, InputFunctionToolCall, InputItem, InputToolSearchCall, ResponsesInput, ToolCallOutput, ToolChoice, ToolSearchCall, ToolSearchOutputMessage, @@ -553,7 +552,6 @@ fn request_contains_tool_search_state(request: &RequestPayload, input: &Response .is_some_and(|tools| tools.iter().any(tool_activates_tool_search)) } - pub(crate) fn request_has_tool_search_state(request: &RequestPayload) -> bool { request_contains_tool_search_state(request, &request.input) } @@ -707,319 +705,73 @@ pub(crate) fn invalid_upstream_withheld_function_call() -> ToolError { ToolError::UpstreamWithheldFunctionCall } -const MAX_STREAM_FUNCTION_BYTES: usize = 256 * 1024; - -#[derive(Default)] -pub(crate) struct ToolSearchStreamState { - active: HashMap, - completed: HashMap, - canonical_calls: HashMap, - pending: HashMap, - argument_bytes: HashMap, - unfinished_item_ids: HashSet, - emitted_added: HashSet, - terminal_failure: bool, -} - -impl fmt::Debug for ToolSearchStreamState { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ToolSearchStreamState") - .field("active_count", &self.active.len()) - .field("completed_count", &self.completed.len()) - .field("canonical_call_count", &self.canonical_calls.len()) - .field("pending_count", &self.pending.len()) - .field("argument_stream_count", &self.argument_bytes.len()) - .field("unfinished_item_count", &self.unfinished_item_ids.len()) - .field("emitted_added_count", &self.emitted_added.len()) - .field("terminal_failure", &self.terminal_failure) - .finish() +pub(crate) fn started_public_call(call: &FunctionToolCall) -> Result { + if call.id.trim().is_empty() + || call.call_id.trim().is_empty() + || call.name != TOOL_SEARCH_NAME + || call.namespace.is_some() + { + return Err(invalid_upstream_search_call()); } + Ok(ToolSearchCall { + id: public_item_id(&call.id), + call_id: call.call_id.clone(), + execution: crate::types::tools::ToolSearchExecution::Client, + arguments: Map::new(), + status: ToolSearchStatus::InProgress, + }) } -impl ToolSearchStreamState { - pub(crate) fn reset(&mut self) { - *self = Self::default(); +pub(crate) fn completed_public_call(call: &FunctionToolCall) -> Result { + if call.status != MessageStatus::Completed { + return Err(invalid_upstream_search_call()); } + let mut public = started_public_call(call)?; + public.arguments = json_object(&call.arguments)?; + public.status = ToolSearchStatus::Completed; + Ok(public) +} - pub(crate) fn prepare_line( - &mut self, - line: &str, - enabled: bool, - withheld_function_names: &HashSet, - ) -> Result, ToolError> { - let tracking_search = !self.active.is_empty() || !self.pending.is_empty() || !self.canonical_calls.is_empty(); - if !enabled && withheld_function_names.is_empty() && !tracking_search && !might_contain_tool_search_wire(line) { - return Ok(None); - } - let Some(mut frame) = normalize_sse_line(line) else { - return Ok(None); +pub(crate) fn project_synthetic_call( + call: &FunctionToolCall, + discard_incomplete: bool, + unfinished_stream_call: bool, +) -> Result, ToolError> { + if unfinished_stream_call || call.status != MessageStatus::Completed { + return if discard_incomplete { + Ok(None) + } else { + Err(invalid_upstream_search_call()) }; - let native = frame - .wire - .rest - .get("item") - .and_then(|item| item.get("type")) - .and_then(Value::as_str) - == Some("tool_search_call"); - let tracking_native = native || !self.active.is_empty() || !self.canonical_calls.is_empty(); - if !enabled && withheld_function_names.is_empty() && !tracking_native { - return Ok(None); - } - validate_withheld_stream_frame(&frame, withheld_function_names)?; - if matches!( - frame.event_type, - SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete - ) { - self.terminal_failure = true; - } - if !enabled && !tracking_native { - return Ok(None); - } - let native_call = native.then(|| adapt_native_stream_frame(&mut frame)).transpose()?; - self.validate_function_frame(&frame)?; - if let Some(call) = native_call { - let output_index = frame - .wire - .output_index - .and_then(|index| u32::try_from(index).ok()) - .unwrap_or_default(); - let mut started = call.clone(); - started.arguments.clear(); - started.status = ToolSearchStatus::InProgress; - self.active.insert(output_index, started); - self.unfinished_item_ids.insert(call.id.clone()); - if call.status == ToolSearchStatus::Completed { - self.canonical_calls.insert(call.id.clone(), call.clone()); - self.completed.insert(output_index, call.clone()); - self.unfinished_item_ids.remove(&call.id); - } - } - let canonicalized_output_index = event_output_index(&frame.payload) - .filter(|output_index| self.active.contains_key(output_index)) - .filter(|output_index| frame.wire.output_index != Some(u64::from(*output_index))); - if let Some(output_index) = canonicalized_output_index { - frame.wire.output_index = Some(u64::from(output_index)); - } - if native || canonicalized_output_index.is_some() { - let wire = serialize_to_string(&frame.wire).map_err(|_| invalid_upstream_search_call())?; - return Ok(Some(format!("data: {wire}"))); - } - Ok(None) - } - - fn validate_function_frame(&mut self, frame: &EventFrame) -> Result<(), ToolError> { - match &frame.payload { - EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - output_index, - name, - item_id, - .. - } => { - let item = stream_item(frame)?; - match name.as_deref() { - Some(TOOL_SEARCH_NAME) => { - let call = strict_started_function(item)?; - self.start(*output_index, &call); - } - Some(_) => {} - None => { - self.pending.insert(*output_index, item.clone()); - if !item_id.is_empty() { - self.unfinished_item_ids.insert(item_id.clone()); - } - } - } - } - EventPayload::FunctionCallArgsDelta { - delta, output_index, .. - } if self.active.contains_key(output_index) || self.pending.contains_key(output_index) => { - let bytes = self.argument_bytes.entry(*output_index).or_default(); - *bytes = bytes.saturating_add(delta.len()); - if *bytes > MAX_STREAM_FUNCTION_BYTES { - return Err(invalid_upstream_search_call()); - } - } - EventPayload::FunctionCallArgsDone { - arguments, - name, - output_index, - .. - } => { - if name == TOOL_SEARCH_NAME || self.active.contains_key(output_index) { - if arguments.len() > MAX_STREAM_FUNCTION_BYTES || json_object(arguments).is_err() { - return Err(invalid_upstream_search_call()); - } - if !self.active.contains_key(output_index) { - let mut item = self - .pending - .remove(output_index) - .ok_or_else(invalid_upstream_search_call)?; - item.as_object_mut() - .ok_or_else(invalid_upstream_search_call)? - .insert("name".to_owned(), Value::String(TOOL_SEARCH_NAME.to_owned())); - let call = strict_started_function(&item)?; - self.start(*output_index, &call); - } - } else { - self.clear_pending(*output_index); - } - } - EventPayload::OutputItemDone { - item_type: SSEItemType::FunctionCall, - output_index, - item, - .. - } => { - let name = item.get("name").and_then(Value::as_str); - if name == Some(TOOL_SEARCH_NAME) || self.active.contains_key(output_index) { - if item - .get("arguments") - .and_then(Value::as_str) - .is_some_and(|arguments| arguments.len() > MAX_STREAM_FUNCTION_BYTES) - { - return Err(invalid_upstream_search_call()); - } - let function = strict_function_call(item)?; - if !self.active.contains_key(output_index) { - self.start(*output_index, &function); - } - if function.status == MessageStatus::Completed { - let public = ToolSearchCall::try_from(&function)?; - self.canonical_calls.insert(function.id.clone(), public.clone()); - self.completed.insert(*output_index, public); - self.unfinished_item_ids.remove(&function.id); - } - } else { - self.clear_pending(*output_index); - } - } - EventPayload::Response { .. } if frame.event_type == SSEEventType::ResponseCompleted => { - validate_terminal_output(frame, true)?; - } - EventPayload::Response { .. } - if matches!( - frame.event_type, - SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete - ) => - { - validate_terminal_output(frame, false)?; - } - _ => {} - } - Ok(()) - } - - fn start(&mut self, output_index: u32, function: &FunctionToolCall) { - if let Ok(public) = ToolSearchCall::started_from_function(function) { - self.unfinished_item_ids.insert(function.id.clone()); - self.active.insert(output_index, public); - self.pending.remove(&output_index); - } - } - - fn clear_pending(&mut self, output_index: u32) { - if let Some(item) = self.pending.remove(&output_index) - && let Some(item_id) = item.get("id").and_then(Value::as_str) - { - self.unfinished_item_ids.remove(item_id); - } - self.argument_bytes.remove(&output_index); - } - - pub(crate) fn translate_frames(&mut self, frames: Vec) -> Result, ToolError> { - let mut public = Vec::with_capacity(frames.len()); - for frame in frames { - let output_index = frame.wire.output_index.and_then(|index| u32::try_from(index).ok()); - let is_function_added = matches!( - frame.payload, - EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - .. - } - ); - if is_function_added && output_index.is_some_and(|index| self.active.contains_key(&index)) { - let index = output_index.unwrap_or_default(); - if self.emitted_added.insert(index) { - public.push(public_stream_frame( - SSEEventType::OutputItemAdded, - index, - self.active.get(&index).ok_or_else(invalid_upstream_search_call)?, - )?); - } - continue; - } - if matches!( - frame.payload, - EventPayload::FunctionCallArgsDelta { .. } | EventPayload::FunctionCallArgsDone { .. } - ) && output_index.is_some_and(|index| self.active.contains_key(&index)) - { - continue; - } - let is_function_done = matches!( - frame.payload, - EventPayload::OutputItemDone { - item_type: SSEItemType::FunctionCall, - .. - } - ); - if is_function_done && output_index.is_some_and(|index| self.active.contains_key(&index)) { - let index = output_index.unwrap_or_default(); - if self.emitted_added.insert(index) { - public.push(public_stream_frame( - SSEEventType::OutputItemAdded, - index, - self.active.get(&index).ok_or_else(invalid_upstream_search_call)?, - )?); - } - if let Some(done) = self.completed.remove(&index) { - public.push(public_stream_frame(SSEEventType::OutputItemDone, index, &done)?); - } - self.active.remove(&index); - self.argument_bytes.remove(&index); - continue; - } - public.push(frame); - } - Ok(public) - } - - pub(crate) fn finish(&self) -> Result<(), ToolError> { - let has_unfinished_active = self - .active - .keys() - .any(|output_index| !self.completed.contains_key(output_index)); - if !self.terminal_failure && (has_unfinished_active || !self.pending.is_empty()) { - return Err(invalid_upstream_search_call()); - } - Ok(()) } + completed_public_call(call).map(Some) +} - pub(crate) fn unfinished_item_ids(&self) -> &HashSet { - &self.unfinished_item_ids +pub(crate) fn project_native_call( + call: &ToolSearchCall, + discard_incomplete: bool, +) -> Result, ToolError> { + if call.status == ToolSearchStatus::Completed { + return Ok(Some(call.clone())); } - - pub(crate) fn canonical_call(&self, internal_item_id: &str) -> Option<&ToolSearchCall> { - self.canonical_calls.get(internal_item_id) + if discard_incomplete { + return Ok(None); } + Err(invalid_upstream_search_call()) } -fn event_output_index(payload: &EventPayload) -> Option { - match payload { - EventPayload::OutputItemAdded { output_index, .. } - | EventPayload::OutputItemDone { output_index, .. } - | EventPayload::FunctionCallArgsDelta { output_index, .. } - | EventPayload::FunctionCallArgsDone { output_index, .. } => Some(*output_index), - _ => None, +pub(crate) fn ensure_function_is_available(is_withheld: bool) -> Result<(), ToolError> { + if is_withheld { + return Err(invalid_upstream_withheld_function_call()); } + Ok(()) } -fn stream_item(frame: &EventFrame) -> Result<&Value, ToolError> { - frame.wire.rest.get("item").ok_or_else(invalid_upstream_search_call) +pub(crate) fn validate_public_arguments(arguments: &str) -> Result<(), ToolError> { + json_object(arguments).map(|_| ()) } -fn strict_started_function(item: &Value) -> Result { +pub(crate) fn strict_started_function(item: &Value) -> Result { let function = strict_function_call(item)?; if function.status != MessageStatus::InProgress || !function.arguments.is_empty() { return Err(invalid_upstream_search_call()); @@ -1027,169 +779,47 @@ fn strict_started_function(item: &Value) -> Result Ok(function) } -fn strict_function_call(item: &Value) -> Result { - BlockingFunctionToolCall::try_from(item).and_then(FunctionToolCall::try_from) -} - -fn json_object(arguments: &str) -> Result, ToolError> { - deserialize_from_str(arguments).map_err(|_| invalid_upstream_search_call()) +#[derive(Debug, Deserialize)] +struct StrictFunctionToolCall { + id: String, + call_id: String, + name: String, + #[serde(default)] + namespace: Option, + arguments: String, + status: MessageStatus, } -fn adapt_native_stream_frame(frame: &mut EventFrame) -> Result { - let item = stream_item(frame)?; - let arguments = item - .get("arguments") - .and_then(Value::as_object) - .ok_or_else(invalid_upstream_search_call) - .and_then(serialize_stream_arguments)?; - let item = item.clone(); - let public = ToolSearchCall::from_blocking_output(item)?; - let status = match public.status { - ToolSearchStatus::Completed => MessageStatus::Completed, - ToolSearchStatus::InProgress | ToolSearchStatus::Incomplete => MessageStatus::InProgress, - }; - if frame.event_type == SSEEventType::OutputItemAdded - && (public.status != ToolSearchStatus::InProgress || !public.arguments.is_empty()) - { +pub(crate) fn strict_function_call(item: &Value) -> Result { + let call: StrictFunctionToolCall = + deserialize_from_value(item.clone()).map_err(|_| invalid_upstream_search_call())?; + if call.namespace.is_some() { return Err(invalid_upstream_search_call()); } - let function = FunctionToolCall { - id: public.id.clone(), - call_id: public.call_id.clone(), - name: TOOL_SEARCH_NAME.to_owned(), + let call = FunctionToolCall { + id: call.id, + call_id: call.call_id, + name: call.name, namespace: None, - arguments: if frame.event_type == SSEEventType::OutputItemAdded { - String::new() - } else { - arguments - }, - status, + arguments: call.arguments, + status: call.status, }; - let item = - serialize_to_value(&OutputItem::FunctionCall(function.clone())).map_err(|_| invalid_upstream_search_call())?; - frame.wire.rest.insert("item".to_owned(), item.clone()); - frame.payload = match frame.event_type { - SSEEventType::OutputItemAdded => EventPayload::OutputItemAdded { - item_id: function.id, - item_type: SSEItemType::FunctionCall, - output_index: frame - .wire - .output_index - .and_then(|index| u32::try_from(index).ok()) - .unwrap_or_default(), - name: Some(function.name), - namespace: None, - call_id: Some(function.call_id), - }, - SSEEventType::OutputItemDone => EventPayload::OutputItemDone { - item_id: function.id, - item_type: SSEItemType::FunctionCall, - output_index: frame - .wire - .output_index - .and_then(|index| u32::try_from(index).ok()) - .unwrap_or_default(), - item, - }, - _ => return Err(invalid_upstream_search_call()), - }; - Ok(public) -} - -fn serialize_stream_arguments(arguments: &Map) -> Result { - let arguments = serialize_to_string(arguments).map_err(|_| invalid_upstream_search_call())?; - if arguments.len() > MAX_STREAM_FUNCTION_BYTES { - return Err(invalid_upstream_search_call()); + started_public_call(&call)?; + if call.status == MessageStatus::Completed { + json_object(&call.arguments)?; } - Ok(arguments) + Ok(call) } -fn public_stream_frame( - event_type: SSEEventType, - output_index: u32, - call: &ToolSearchCall, -) -> Result { - let item = - serialize_to_value(&OutputItem::ToolSearchCall(call.clone())).map_err(|_| invalid_upstream_search_call())?; - let mut rest = Map::new(); - rest.insert("item".to_owned(), item); - let mut frame = EventFrame::synthetic(event_type, rest).ok_or_else(invalid_upstream_search_call)?; - frame.wire.output_index = Some(u64::from(output_index)); - Ok(frame) -} - -fn validate_withheld_stream_frame( - frame: &EventFrame, - withheld_function_names: &HashSet, -) -> Result<(), ToolError> { - let lifecycle_name = match &frame.payload { - EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - name: Some(name), - .. - } - | EventPayload::FunctionCallArgsDone { name, .. } => Some(name.as_str()), - EventPayload::OutputItemDone { - item_type: SSEItemType::FunctionCall, - item, - .. - } => item.get("name").and_then(Value::as_str), - _ => None, - }; - let terminal_has_withheld = frame.event_type == SSEEventType::ResponseCompleted - && frame - .wire - .rest - .get("response") - .and_then(|response| response.get("output")) - .and_then(Value::as_array) - .is_some_and(|output| { - output.iter().any(|item| { - item.get("type").and_then(Value::as_str) == Some("function_call") - && item - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| withheld_function_names.contains(name)) - }) - }); - if terminal_has_withheld || lifecycle_name.is_some_and(|name| withheld_function_names.contains(name)) { - return Err(invalid_upstream_withheld_function_call()); +pub(crate) fn strict_native_call(item: Value) -> Result { + if item.get("namespace").is_some_and(|namespace| !namespace.is_null()) { + return Err(invalid_upstream_search_call()); } - Ok(()) + deserialize_from_value(item).map_err(|_| invalid_upstream_search_call()) } -fn validate_terminal_output(frame: &EventFrame, completed: bool) -> Result<(), ToolError> { - let Some(output) = frame - .wire - .rest - .get("response") - .and_then(|response| response.get("output")) - .and_then(Value::as_array) - else { - return Ok(()); - }; - for item in output { - match item.get("type").and_then(Value::as_str) { - Some("tool_search_call") => { - let call = ToolSearchCall::from_blocking_output(item.clone())?; - serialize_stream_arguments(&call.arguments)?; - if completed && call.status != ToolSearchStatus::Completed { - return Err(invalid_upstream_search_call()); - } - } - Some("function_call") if item.get("name").and_then(Value::as_str) == Some(TOOL_SEARCH_NAME) => { - let call = strict_function_call(item)?; - if call.arguments.len() > MAX_STREAM_FUNCTION_BYTES { - return Err(invalid_upstream_search_call()); - } - if completed && call.status != MessageStatus::Completed { - return Err(invalid_upstream_search_call()); - } - } - _ => {} - } - } - Ok(()) +fn json_object(arguments: &str) -> Result, ToolError> { + deserialize_from_str(arguments).map_err(|_| invalid_upstream_search_call()) } pub(crate) fn validate_blocking_response( @@ -1220,7 +850,7 @@ pub(crate) fn validate_blocking_response( } match item.get("type").and_then(Value::as_str) { Some("tool_search_call") => { - let call = ToolSearchCall::from_blocking_output(item.clone())?; + let call = strict_native_call(item.clone())?; if !discard_unfinished && call.status != ToolSearchStatus::Completed { return Err(invalid_upstream_search_call()); } @@ -2110,10 +1740,6 @@ mod tests { param } - fn sse_line(value: &Value) -> String { - format!("data: {value}") - } - #[test] fn handler_validates_and_normalizes_exactly_one_function() { let param = param(json!({ @@ -2162,6 +1788,68 @@ mod tests { ); } + #[test] + fn synthetic_public_call_construction_is_validated_in_tool_layer() { + let valid = FunctionToolCall { + id: "fc_search".to_owned(), + call_id: "call_search".to_owned(), + name: TOOL_SEARCH_NAME.to_owned(), + namespace: None, + arguments: r#"{"query":"weather"}"#.to_owned(), + status: MessageStatus::Completed, + }; + let started = started_public_call(&valid).expect("valid started call"); + assert_eq!(started.id, "tsc_search"); + assert_eq!(started.status, ToolSearchStatus::InProgress); + let completed = completed_public_call(&valid).expect("valid completed call"); + assert_eq!(completed.arguments["query"], "weather"); + + for invalid in [ + FunctionToolCall { + name: "ordinary".to_owned(), + ..valid.clone() + }, + FunctionToolCall { + namespace: Some("catalog".to_owned()), + ..valid.clone() + }, + FunctionToolCall { + arguments: "[]".to_owned(), + ..valid.clone() + }, + FunctionToolCall { + status: MessageStatus::InProgress, + ..valid.clone() + }, + ] { + assert!(completed_public_call(&invalid).is_err()); + } + } + + #[test] + fn terminal_projection_rejects_or_discards_unfinished_calls() { + let synthetic = FunctionToolCall { + id: "fc_search".to_owned(), + call_id: "call_search".to_owned(), + name: TOOL_SEARCH_NAME.to_owned(), + namespace: None, + arguments: String::new(), + status: MessageStatus::InProgress, + }; + assert!(project_synthetic_call(&synthetic, false, true).is_err()); + assert!(project_synthetic_call(&synthetic, true, true).unwrap().is_none()); + + let native = ToolSearchCall { + id: "tsc_search".to_owned(), + call_id: "call_search".to_owned(), + execution: crate::types::tools::ToolSearchExecution::Client, + arguments: Map::new(), + status: ToolSearchStatus::Incomplete, + }; + assert!(project_native_call(&native, false).is_err()); + assert!(project_native_call(&native, true).unwrap().is_none()); + } + #[test] fn registry_requires_tool_search_preparation_before_upstream_conversion() { let mut request: RequestPayload = serde_json::from_value(json!({ @@ -2289,51 +1977,6 @@ mod tests { .expect("inactive ordinary function keeps generic compatibility defaults"); } - #[test] - fn stream_rejects_oversized_native_done_arguments() { - let mut stream = ToolSearchStreamState::default(); - let line = sse_line(&json!({ - "type": "response.output_item.done", - "output_index": 0, - "item": { - "type": "tool_search_call", - "id": "provider-native-id", - "call_id": "call_search", - "execution": "client", - "status": "completed", - "arguments": {"query": "x".repeat(MAX_STREAM_FUNCTION_BYTES)} - } - })); - - assert!(matches!( - stream.prepare_line(&line, false, &HashSet::new()), - Err(ToolError::InvalidUpstreamToolSearch) - )); - } - - #[test] - fn stream_rejects_oversized_synthetic_done_arguments() { - let mut stream = ToolSearchStreamState::default(); - let arguments = format!("{{\"query\":\"{}\"}}", "x".repeat(MAX_STREAM_FUNCTION_BYTES)); - let line = sse_line(&json!({ - "type": "response.output_item.done", - "output_index": 0, - "item": { - "type": "function_call", - "id": "fc_search", - "call_id": "call_search", - "name": "tool_search", - "status": "completed", - "arguments": arguments - } - })); - - assert!(matches!( - stream.prepare_line(&line, true, &HashSet::new()), - Err(ToolError::InvalidUpstreamToolSearch) - )); - } - #[test] fn prepared_response_tools_remove_request_scoped_mcp_secrets_and_discovery() { let mut request: RequestPayload = serde_json::from_value(json!({ diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index 395b40a4..931eec15 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -3,10 +3,10 @@ use serde_json::Value; use crate::events::EventPayload; use crate::executor::error::ExecutorError; -use crate::tool::{ToolError, ToolRegistry, tool_search}; +use crate::tool::ToolRegistry; use crate::types::event::MessageStatus; use crate::types::tools::{ToolSearchExecution, ToolSearchStatus}; -use crate::utils::common::{deserialize_from_str, deserialize_from_value, deserialize_from_value_opt}; +use crate::utils::common::deserialize_from_value_opt; use crate::utils::uuid7_str; use super::input::{ @@ -104,30 +104,6 @@ pub struct FunctionToolCall { pub status: MessageStatus, } -/// Strict non-streaming wire shape used before tool classification. -/// -/// [`FunctionToolCall`] intentionally supplies compatibility defaults for -/// ordinary functions. This private shape lets the executor remember whether -/// a call would be valid if its name is later classified as tool search. -#[derive(Debug, Deserialize)] -pub(crate) struct BlockingFunctionToolCall { - id: String, - call_id: String, - name: String, - #[serde(default)] - namespace: Option, - arguments: String, - status: MessageStatus, -} - -impl TryFrom<&Value> for BlockingFunctionToolCall { - type Error = ToolError; - - fn try_from(value: &Value) -> Result { - deserialize_from_value(value.clone()).map_err(|_| tool_search::invalid_upstream_search_call()) - } -} - /// A newly emitted public client tool-search call. /// /// Unlike replay input, execution and status have no serde defaults: response @@ -143,73 +119,6 @@ pub struct ToolSearchCall { pub status: ToolSearchStatus, } -impl TryFrom<&FunctionToolCall> for ToolSearchCall { - type Error = ToolError; - - fn try_from(call: &FunctionToolCall) -> Result { - let mut public = Self::started_from_function(call)?; - if call.status != MessageStatus::Completed { - return Err(tool_search::invalid_upstream_search_call()); - } - public.arguments = serde_json::from_str::(&call.arguments) - .ok() - .and_then(|value| value.as_object().cloned()) - .ok_or_else(tool_search::invalid_upstream_search_call)?; - public.status = ToolSearchStatus::Completed; - Ok(public) - } -} - -impl ToolSearchCall { - pub(crate) fn from_blocking_output(value: Value) -> Result { - if value.get("namespace").is_some_and(|namespace| !namespace.is_null()) { - return Err(tool_search::invalid_upstream_search_call()); - } - deserialize_from_value(value).map_err(|_| tool_search::invalid_upstream_search_call()) - } - - pub(crate) fn started_from_function(call: &FunctionToolCall) -> Result { - if call.id.trim().is_empty() - || call.call_id.trim().is_empty() - || call.name != "tool_search" - || call.namespace.is_some() - { - return Err(tool_search::invalid_upstream_search_call()); - } - Ok(Self { - id: tool_search::public_item_id(&call.id), - call_id: call.call_id.clone(), - execution: ToolSearchExecution::Client, - arguments: serde_json::Map::new(), - status: ToolSearchStatus::InProgress, - }) - } -} - -impl TryFrom for FunctionToolCall { - type Error = ToolError; - - fn try_from(call: BlockingFunctionToolCall) -> Result { - if call.namespace.is_some() { - return Err(tool_search::invalid_upstream_search_call()); - } - let call = Self { - id: call.id, - call_id: call.call_id, - name: call.name, - namespace: None, - arguments: call.arguments, - status: call.status, - }; - ToolSearchCall::started_from_function(&call)?; - if call.status == MessageStatus::Completed { - deserialize_from_str::>(&call.arguments) - .map_err(|_| tool_search::invalid_upstream_search_call())?; - } - Ok(call) - } -} - /// A freeform custom tool invocation. /// /// `input` is opaque text and must not be parsed as function-call JSON. @@ -282,6 +191,30 @@ impl TryFrom<&EventPayload> for FunctionToolCall { } } +impl TryFrom<&EventPayload> for ToolSearchCall { + type Error = ExecutorError; + + fn try_from(payload: &EventPayload) -> Result { + let EventPayload::OutputItemAdded { item_id, call_id, .. } = payload else { + return Err(ExecutorError::ParseError("expected OutputItemAdded payload".into())); + }; + let call_id = call_id + .as_deref() + .filter(|call_id| !call_id.trim().is_empty()) + .ok_or_else(|| ExecutorError::ParseError("tool_search_call is missing call_id".into()))?; + if item_id.trim().is_empty() { + return Err(ExecutorError::ParseError("tool_search_call is missing id".into())); + } + Ok(Self { + id: item_id.clone(), + call_id: call_id.to_owned(), + execution: ToolSearchExecution::Client, + arguments: serde_json::Map::new(), + status: ToolSearchStatus::InProgress, + }) + } +} + impl TryFrom<&EventPayload> for CustomToolCall { type Error = ExecutorError; @@ -764,6 +697,17 @@ impl ApplyDone for FunctionToolCall { } } +impl ApplyDone for ToolSearchCall { + fn apply_done(&mut self, payload: &EventPayload, _buffer: &mut String) { + let EventPayload::OutputItemDone { item, .. } = payload else { + return; + }; + if let Some(call) = deserialize_from_value_opt(item.clone()) { + *self = call; + } + } +} + impl ApplyDone for CustomToolCall { fn apply_done(&mut self, payload: &EventPayload, buffer: &mut String) { match payload { @@ -970,42 +914,6 @@ mod tests { } } - #[test] - fn synthetic_function_call_conversion_is_validated() { - let valid = FunctionToolCall { - id: "fc_search".to_owned(), - call_id: "call_search".to_owned(), - name: "tool_search".to_owned(), - namespace: None, - arguments: r#"{"query":"weather"}"#.to_owned(), - status: MessageStatus::Completed, - }; - let public = ToolSearchCall::try_from(&valid).unwrap(); - assert_eq!(public.id, "tsc_search"); - assert_eq!(public.arguments["query"], "weather"); - - for invalid in [ - FunctionToolCall { - name: "ordinary".to_owned(), - ..valid.clone() - }, - FunctionToolCall { - namespace: Some("tools".to_owned()), - ..valid.clone() - }, - FunctionToolCall { - arguments: "[]".to_owned(), - ..valid.clone() - }, - FunctionToolCall { - status: MessageStatus::InProgress, - ..valid.clone() - }, - ] { - assert!(ToolSearchCall::try_from(&invalid).is_err()); - } - } - #[test] fn compaction_output_item_round_trips_with_type_tag() { let item: OutputItem = serde_json::from_value(serde_json::json!({