diff --git a/crates/agentic-server-core/benches/executor_throughput.rs b/crates/agentic-server-core/benches/executor_throughput.rs index f56bb5f..7c3803f 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, cache_salt: None, } } diff --git a/crates/agentic-server-core/src/executor/modes/conversation.rs b/crates/agentic-server-core/src/executor/modes/conversation.rs index 524f0d7..3229bd2 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, cache_salt: None, }; RequestContext { diff --git a/crates/agentic-server-core/src/executor/modes/response.rs b/crates/agentic-server-core/src/executor/modes/response.rs index c629dbf..ae771a4 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, cache_salt: None, }; RequestContext { diff --git a/crates/agentic-server-core/src/tool/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index 540802b..e70b67d 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 31341ed..050466e 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_salt: Option, } @@ -64,6 +65,7 @@ pub struct UpstreamRequest<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option<&'a Value>, #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, pub cache_salt: Option<&'a str>, } @@ -89,6 +91,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_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_built_in_tool { + Some(false) + } else { + self.parallel_tool_calls + }; + let renamed_tools = self .tools .as_deref() @@ -113,9 +127,16 @@ impl RequestPayload { max_output_tokens: self.max_output_tokens, truncation: self.truncation.as_deref(), metadata: self.metadata.as_ref(), + parallel_tool_calls, cache_salt: self.cache_salt.as_deref(), }) } + + fn declares_built_in_tool(&self) -> bool { + self.tools + .as_deref() + .is_some_and(|tools| tools.iter().any(ResponsesTool::is_gateway_owned)) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -234,6 +255,135 @@ 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_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_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() { + 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() { + 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() { + 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); + } + } + + 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] 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 dcf9f8b..772a464 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, cache_salt: None, } } diff --git a/crates/agentic-server-core/tests/support/mod.rs b/crates/agentic-server-core/tests/support/mod.rs index 7f28df4..206c847 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, cache_salt: 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 3d28481..e57b8c3 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, cache_salt: None, }; @@ -668,6 +669,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, cache_salt: None, }; @@ -716,6 +718,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, cache_salt: None, }; @@ -763,6 +766,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, cache_salt: None, }; let continuation = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap(); @@ -833,6 +837,7 @@ async fn execute_accumulates_usage_across_web_search_model_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -876,6 +881,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, cache_salt: None, }; @@ -958,6 +964,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, cache_salt: None, }; @@ -1024,6 +1031,7 @@ async fn execute_runs_multiple_web_search_calls_concurrently() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -1072,6 +1080,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, cache_salt: None, }; @@ -1122,6 +1131,7 @@ async fn execute_returns_incomplete_after_max_gateway_tool_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -1172,6 +1182,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, cache_salt: None, }; @@ -1229,6 +1240,7 @@ async fn execute_runs_large_gateway_fanout_without_hard_cap() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -1292,6 +1304,7 @@ async fn stream_error_events_escape_error_messages() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -1367,6 +1380,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; @@ -1393,6 +1407,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; let _ = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap(); @@ -1463,6 +1478,7 @@ async fn stream_returns_incomplete_after_max_gateway_tool_rounds() { max_output_tokens: Some(1024), truncation: None, metadata: None, + parallel_tool_calls: None, cache_salt: None, }; diff --git a/crates/agentic-server/tests/responses_test.rs b/crates/agentic-server/tests/responses_test.rs index 52894be..cd78fa1 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