From 51eabd94d5d580bec50ee3c8099660a10fdb8fc5 Mon Sep 17 00:00:00 2001 From: harivilasp Date: Wed, 15 Jul 2026 17:09:39 -0700 Subject: [PATCH 1/6] fix: preserve parallel tool call setting Signed-off-by: harivilasp --- .../benches/executor_throughput.rs | 1 + .../src/executor/modes/conversation.rs | 1 + .../src/executor/modes/response.rs | 1 + .../src/types/request_response.rs | 18 ++++++++++++ .../tests/dispatch_loop_cassette_test.rs | 1 + .../agentic-server-core/tests/support/mod.rs | 1 + .../tests/web_search_tool_test.rs | 16 +++++++++++ crates/agentic-server/tests/responses_test.rs | 28 +++++++++++++++++++ 8 files changed, 67 insertions(+) diff --git a/crates/agentic-server-core/benches/executor_throughput.rs b/crates/agentic-server-core/benches/executor_throughput.rs index 3a18797d..946352d9 100644 --- a/crates/agentic-server-core/benches/executor_throughput.rs +++ b/crates/agentic-server-core/benches/executor_throughput.rs @@ -145,6 +145,7 @@ fn make_request(input: &str, stream: bool, prev_id: Option) -> RequestPa max_output_tokens: None, truncation: None, metadata: None, + parallel_tool_calls: None, } } diff --git a/crates/agentic-server-core/src/executor/modes/conversation.rs b/crates/agentic-server-core/src/executor/modes/conversation.rs index 0d1374d5..03e10bbe 100644 --- a/crates/agentic-server-core/src/executor/modes/conversation.rs +++ b/crates/agentic-server-core/src/executor/modes/conversation.rs @@ -141,6 +141,7 @@ mod tests { max_output_tokens: None, truncation: None, metadata: None, + parallel_tool_calls: None, }; RequestContext { enriched_request: req.clone(), diff --git a/crates/agentic-server-core/src/executor/modes/response.rs b/crates/agentic-server-core/src/executor/modes/response.rs index a67cf3fa..22498508 100644 --- a/crates/agentic-server-core/src/executor/modes/response.rs +++ b/crates/agentic-server-core/src/executor/modes/response.rs @@ -120,6 +120,7 @@ mod tests { max_output_tokens: None, truncation: None, metadata: None, + parallel_tool_calls: None, }; RequestContext { enriched_request: req.clone(), diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 5da33fd9..2de6284d 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -28,6 +28,7 @@ pub struct RequestPayload { pub max_output_tokens: Option, pub truncation: Option, pub metadata: Option, + pub parallel_tool_calls: Option, } fn default_true() -> bool { @@ -61,6 +62,8 @@ pub struct UpstreamRequest<'a> { pub truncation: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, } // serde's `skip_serializing_if` requires a `&Option` receiver, so the @@ -109,6 +112,7 @@ impl RequestPayload { max_output_tokens: self.max_output_tokens, truncation: self.truncation.as_deref(), metadata: self.metadata.as_ref(), + parallel_tool_calls: self.parallel_tool_calls, }) } } @@ -214,6 +218,20 @@ mod tests { assert_eq!(value["input"], "hi"); } + #[test] + fn to_upstream_request_preserves_parallel_tool_calls() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": false + })) + .unwrap(); + + let upstream = payload.to_upstream_request(false).expect("valid upstream request"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + } + #[test] fn to_upstream_request_flattens_namespace_and_skips_unknown_tools() { let payload: RequestPayload = serde_json::from_value(serde_json::json!({ diff --git a/crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs b/crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs index a0a5a4ea..fcc983d4 100644 --- a/crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs +++ b/crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs @@ -127,6 +127,7 @@ fn request(text: &str, tools: Option>) -> RequestPayload { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, } } diff --git a/crates/agentic-server-core/tests/support/mod.rs b/crates/agentic-server-core/tests/support/mod.rs index 61c01837..e7c30b65 100644 --- a/crates/agentic-server-core/tests/support/mod.rs +++ b/crates/agentic-server-core/tests/support/mod.rs @@ -369,6 +369,7 @@ pub fn make_request( max_output_tokens: None, truncation: None, metadata: None, + parallel_tool_calls: None, } } diff --git a/crates/agentic-server-core/tests/web_search_tool_test.rs b/crates/agentic-server-core/tests/web_search_tool_test.rs index 92813f5b..94850770 100644 --- a/crates/agentic-server-core/tests/web_search_tool_test.rs +++ b/crates/agentic-server-core/tests/web_search_tool_test.rs @@ -584,6 +584,7 @@ async fn execute_runs_web_search_and_sends_tool_output_back_to_model() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -667,6 +668,7 @@ async fn execute_relaxes_forced_tool_choice_after_web_search_result() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -714,6 +716,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request( max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -760,6 +763,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request( max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let continuation = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap(); assert!(matches!(continuation, Either::Left(_))); @@ -829,6 +833,7 @@ async fn execute_accumulates_usage_across_web_search_model_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap(); @@ -871,6 +876,7 @@ async fn stream_emits_web_search_lifecycle_events_before_final_payload() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -952,6 +958,7 @@ async fn stream_hides_web_search_function_events_when_name_arrives_on_done() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -1017,6 +1024,7 @@ async fn execute_runs_multiple_web_search_calls_concurrently() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = tokio::time::timeout(Duration::from_secs(2), ExecuteRequest::new(payload, exec_ctx).run()) @@ -1064,6 +1072,7 @@ async fn execute_feeds_web_search_execution_errors_back_to_model() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap(); @@ -1113,6 +1122,7 @@ async fn execute_returns_incomplete_after_max_gateway_tool_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; // Budget exhausted while the model keeps requesting tools → the response is @@ -1162,6 +1172,7 @@ async fn execute_feeds_invalid_web_search_arguments_back_to_model() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap(); @@ -1218,6 +1229,7 @@ async fn execute_runs_large_gateway_fanout_without_hard_cap() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx) @@ -1280,6 +1292,7 @@ async fn stream_error_events_escape_error_messages() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap(); @@ -1354,6 +1367,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap(); @@ -1379,6 +1393,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let _ = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap(); @@ -1448,6 +1463,7 @@ async fn stream_returns_incomplete_after_max_gateway_tool_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, }; let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap(); diff --git a/crates/agentic-server/tests/responses_test.rs b/crates/agentic-server/tests/responses_test.rs index 52894be7..cd78fa10 100644 --- a/crates/agentic-server/tests/responses_test.rs +++ b/crates/agentic-server/tests/responses_test.rs @@ -132,6 +132,34 @@ async fn test_store_false_with_web_search_reaches_executor() { assert_eq!(requests[0]["tools"][0]["name"], "web_search"); } +#[tokio::test] +async fn test_gateway_normalization_preserves_parallel_tool_calls() { + // Arrange + let (llm_url, requests, _h1) = spawn_mock_vllm_json_capture().await; + let (gw_url, _h2) = spawn_gateway(test_state(&test_config(&llm_url))).await; + + // Act + let resp = reqwest::Client::new() + .post(format!("{gw_url}/v1/responses")) + .json(&serde_json::json!({ + "model": "test", + "input": [{"type": "message", "role": "user", "content": "hi"}], + "tools": [{"type": "web_search_preview"}], + "parallel_tool_calls": false, + "store": false, + "stream": false + })) + .send() + .await + .unwrap(); + + // Assert + assert_eq!(resp.status(), 200); + let requests = requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["parallel_tool_calls"], false); +} + #[tokio::test] async fn test_store_false_proxies_large_json_body_to_vllm() { // Arrange From 79059b5935ced973a30054c18cbce92f7ddc0976 Mon Sep 17 00:00:00 2001 From: harivilasp Date: Wed, 15 Jul 2026 22:41:52 -0700 Subject: [PATCH 2/6] fix: validate built-in parallel tool calls Signed-off-by: harivilasp --- .../src/types/request_response.rs | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 2de6284d..f86ce2b8 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -88,6 +88,18 @@ impl RequestPayload { /// flat name collides with a top-level function tool or another namespace /// member. pub fn to_upstream_request(&self, stream: bool) -> Result, ToolError> { + let has_gateway_owned_tool = self.declares_gateway_owned_tool(); + if has_gateway_owned_tool && self.parallel_tool_calls == Some(true) { + return Err(ToolError::Config( + "parallel_tool_calls must be false when using built-in tools".into(), + )); + } + let parallel_tool_calls = if has_gateway_owned_tool { + Some(false) + } else { + self.parallel_tool_calls + }; + let renamed_tools = self .tools .as_deref() @@ -112,7 +124,21 @@ impl RequestPayload { max_output_tokens: self.max_output_tokens, truncation: self.truncation.as_deref(), metadata: self.metadata.as_ref(), - parallel_tool_calls: self.parallel_tool_calls, + parallel_tool_calls, + }) + } + + fn declares_gateway_owned_tool(&self) -> bool { + self.tools.as_deref().is_some_and(|tools| { + tools.iter().any(|tool| { + matches!( + tool, + ResponsesTool::Mcp(_) + | ResponsesTool::WebSearch(_) + | ResponsesTool::FileSearch(_) + | ResponsesTool::CodeInterpreter(_) + ) + }) }) } } @@ -232,6 +258,73 @@ mod tests { assert_eq!(value["parallel_tool_calls"], false); } + #[test] + fn to_upstream_request_allows_parallel_tool_calls_for_client_function_tools() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": true, + "tools": [{"type": "function", "name": "get_weather"}] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("function tools allow parallel calls"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], true); + } + + #[test] + fn to_upstream_request_sets_serial_tool_calls_for_builtin_tools() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "tools": [{"type": "web_search_preview"}] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("built-in tools default to serial tool calls"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + } + + #[test] + fn to_upstream_request_rejects_parallel_tool_calls_for_builtin_tools() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": true, + "tools": [{"type": "web_search_preview"}] + })) + .unwrap(); + + let Err(err) = payload.to_upstream_request(false) else { + panic!("built-in tools should reject parallel_tool_calls=true"); + }; + + assert!(err.to_string().contains("parallel_tool_calls must be false")); + } + + #[test] + fn to_upstream_request_allows_builtin_tools_with_serial_tool_calls() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": false, + "tools": [{"type": "web_search_preview"}] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("serial built-in tool request is valid"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + } + #[test] fn to_upstream_request_flattens_namespace_and_skips_unknown_tools() { let payload: RequestPayload = serde_json::from_value(serde_json::json!({ From cd11ad7ba48f76e00d4b624ebce0ff379d46407c Mon Sep 17 00:00:00 2001 From: harivilasp Date: Thu, 16 Jul 2026 19:48:21 -0700 Subject: [PATCH 3/6] test: cover mixed built-in and function tools Signed-off-by: harivilasp --- .../src/types/request_response.rs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 665396c8..781768e3 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -91,13 +91,13 @@ impl RequestPayload { /// flat name collides with a top-level function tool or another namespace /// member. pub fn to_upstream_request(&self, stream: bool) -> Result, ToolError> { - let has_gateway_owned_tool = self.declares_gateway_owned_tool(); - if has_gateway_owned_tool && self.parallel_tool_calls == Some(true) { + 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( "parallel_tool_calls must be false when using built-in tools".into(), )); } - let parallel_tool_calls = if has_gateway_owned_tool { + let parallel_tool_calls = if has_built_in_tool { Some(false) } else { self.parallel_tool_calls @@ -132,7 +132,7 @@ impl RequestPayload { }) } - fn declares_gateway_owned_tool(&self) -> bool { + fn declares_built_in_tool(&self) -> bool { self.tools.as_deref().is_some_and(|tools| { tools.iter().any(|tool| { matches!( @@ -294,6 +294,26 @@ mod tests { assert_eq!(value["parallel_tool_calls"], true); } + #[test] + fn to_upstream_request_sets_serial_tool_calls_for_mixed_builtin_and_function_tools() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "tools": [ + {"type": "function", "name": "get_weather"}, + {"type": "web_search_preview"} + ] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("mixed built-in and function tools default to serial tool calls"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + assert_eq!(value["tools"].as_array().expect("upstream tools").len(), 2); + } + #[test] fn to_upstream_request_sets_serial_tool_calls_for_builtin_tools() { let payload: RequestPayload = serde_json::from_value(serde_json::json!({ From ffc823ad58153a7ddad5d8b549a7b3255819bb3a Mon Sep 17 00:00:00 2001 From: harivilasp Date: Thu, 16 Jul 2026 21:53:49 -0700 Subject: [PATCH 4/6] Address built-in tool ownership review Signed-off-by: harivilasp --- .../agentic-server-core/src/tool/normalize.rs | 25 +++- .../src/types/request_response.rs | 116 ++++++++++-------- 2 files changed, 92 insertions(+), 49 deletions(-) diff --git a/crates/agentic-server-core/src/tool/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index 540802be..e70b67dc 100644 --- a/crates/agentic-server-core/src/tool/normalize.rs +++ b/crates/agentic-server-core/src/tool/normalize.rs @@ -6,10 +6,33 @@ use crate::utils::common::serialize_to_value_or_custom_default; use super::codex::CodexNamespaceHandler; use super::function::FunctionHandler; use super::handler::{ToolHandler, ToolOutput}; -use super::mcp::McpHandler; +use super::mcp::{McpHandler, maybe_mcp_function}; +use super::registry::ToolType; use super::web_search::web_search_function_tool; impl ResponsesTool { + /// Return the gateway routing type this declaration would register as. + #[must_use] + pub fn tool_type(&self) -> Option { + match self { + Self::Function(p) => match maybe_mcp_function(p) { + Some(params) if !params.is_empty() => Some(ToolType::Mcp), + _ => Some(ToolType::Function), + }, + Self::Mcp(_) => Some(ToolType::Mcp), + Self::WebSearch(_) => Some(ToolType::WebSearch), + Self::FileSearch(_) => Some(ToolType::FileSearch), + Self::CodeInterpreter(_) => Some(ToolType::CodeInterpreter), + Self::Namespace(_) => Some(ToolType::CodexNamespace), + Self::Unknown => None, + } + } + + #[must_use] + pub fn is_gateway_owned(&self) -> bool { + self.tool_type().is_some_and(ToolType::is_gateway_owned) + } + /// Normalise this tool declaration to the `FunctionTool` wire format that vLLM understands. /// /// - `Function` variants convert via [`From<&FunctionToolParam>`] for `FunctionTool`. diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 781768e3..050466e9 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -133,17 +133,9 @@ impl RequestPayload { } fn declares_built_in_tool(&self) -> bool { - self.tools.as_deref().is_some_and(|tools| { - tools.iter().any(|tool| { - matches!( - tool, - ResponsesTool::Mcp(_) - | ResponsesTool::WebSearch(_) - | ResponsesTool::FileSearch(_) - | ResponsesTool::CodeInterpreter(_) - ) - }) - }) + self.tools + .as_deref() + .is_some_and(|tools| tools.iter().any(ResponsesTool::is_gateway_owned)) } } @@ -316,52 +308,80 @@ mod tests { #[test] fn to_upstream_request_sets_serial_tool_calls_for_builtin_tools() { - let payload: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test", - "input": "hi", - "tools": [{"type": "web_search_preview"}] - })) - .unwrap(); - - let upstream = payload - .to_upstream_request(false) - .expect("built-in tools default to serial tool calls"); - let value = serde_json::to_value(upstream).unwrap(); - assert_eq!(value["parallel_tool_calls"], false); + for tool in builtin_tool_declarations() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "tools": [tool] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("built-in tools default to serial tool calls"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + } } #[test] fn to_upstream_request_rejects_parallel_tool_calls_for_builtin_tools() { - let payload: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test", - "input": "hi", - "parallel_tool_calls": true, - "tools": [{"type": "web_search_preview"}] - })) - .unwrap(); - - let Err(err) = payload.to_upstream_request(false) else { - panic!("built-in tools should reject parallel_tool_calls=true"); - }; - - assert!(err.to_string().contains("parallel_tool_calls must be false")); + for tool in builtin_tool_declarations() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": true, + "tools": [tool] + })) + .unwrap(); + + let Err(err) = payload.to_upstream_request(false) else { + panic!("built-in tools should reject parallel_tool_calls=true"); + }; + + assert!(err.to_string().contains("parallel_tool_calls must be false")); + } } #[test] fn to_upstream_request_allows_builtin_tools_with_serial_tool_calls() { - let payload: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test", - "input": "hi", - "parallel_tool_calls": false, - "tools": [{"type": "web_search_preview"}] - })) - .unwrap(); + for tool in builtin_tool_declarations() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": false, + "tools": [tool] + })) + .unwrap(); + + let upstream = payload + .to_upstream_request(false) + .expect("serial built-in tool request is valid"); + let value = serde_json::to_value(upstream).unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + } + } - let upstream = payload - .to_upstream_request(false) - .expect("serial built-in tool request is valid"); - let value = serde_json::to_value(upstream).unwrap(); - assert_eq!(value["parallel_tool_calls"], false); + fn builtin_tool_declarations() -> Vec { + vec![ + serde_json::json!({ + "type": "function", + "name": "read_mcp_resource", + "metadata": { + "server_label": "repo", + "server_url": "http://localhost:9001/mcp" + } + }), + serde_json::json!({ + "type": "mcp", + "name": "read_mcp_resource", + "server_label": "repo", + "server_url": "http://localhost:9001/mcp" + }), + serde_json::json!({"type": "web_search_preview"}), + serde_json::json!({"type": "file_search", "vector_store_ids": ["vs_abc"]}), + serde_json::json!({"type": "code_interpreter"}), + ] } #[test] From ea65d78cd93b8edbed0eb8106400b415660042a0 Mon Sep 17 00:00:00 2001 From: harivilasp Date: Sun, 19 Jul 2026 16:03:25 -0700 Subject: [PATCH 5/6] test: cover mixed built-in tool parallel calls Signed-off-by: harivilasp --- .../src/types/request_response.rs | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 050466e9..02f3f317 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -287,23 +287,33 @@ mod tests { } #[test] - fn to_upstream_request_sets_serial_tool_calls_for_mixed_builtin_and_function_tools() { - let payload: RequestPayload = serde_json::from_value(serde_json::json!({ - "model": "test", - "input": "hi", - "tools": [ - {"type": "function", "name": "get_weather"}, - {"type": "web_search_preview"} - ] - })) - .unwrap(); - - let upstream = payload - .to_upstream_request(false) - .expect("mixed built-in and function tools default to serial tool calls"); - let value = serde_json::to_value(upstream).unwrap(); - assert_eq!(value["parallel_tool_calls"], false); - assert_eq!(value["tools"].as_array().expect("upstream tools").len(), 2); + fn to_upstream_request_validates_parallel_tool_calls_for_mixed_tools() { + for built_in_tool in builtin_tool_declarations() { + for (parallel_tool_calls, should_reject) in [(false, false), (true, true)] { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "parallel_tool_calls": parallel_tool_calls, + "tools": [ + {"type": "function", "name": "get_weather"}, + built_in_tool.clone() + ] + })) + .unwrap(); + + let result = payload.to_upstream_request(false); + if should_reject { + let err = result.expect_err("built-in tools should reject parallel tool calls"); + assert!(err.to_string().contains("parallel_tool_calls must be false")); + } else { + let value = + serde_json::to_value(result.expect("mixed built-in and function tools allow serial calls")) + .unwrap(); + assert_eq!(value["parallel_tool_calls"], false); + assert_eq!(value["tools"].as_array().expect("upstream tools").len(), 2); + } + } + } } #[test] From ef5d0925bd77385ae84802978ec2ee5cd1bec3a8 Mon Sep 17 00:00:00 2001 From: harivilasp Date: Sun, 19 Jul 2026 16:18:59 -0700 Subject: [PATCH 6/6] test: avoid assuming built-in normalization output Signed-off-by: harivilasp --- crates/agentic-server-core/src/types/request_response.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 02f3f317..4742c9af 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -310,7 +310,6 @@ mod tests { serde_json::to_value(result.expect("mixed built-in and function tools allow serial calls")) .unwrap(); assert_eq!(value["parallel_tool_calls"], false); - assert_eq!(value["tools"].as_array().expect("upstream tools").len(), 2); } } }