Skip to content

Commit 8d74b4c

Browse files
authored
fix: preserve parallel tool call setting (vllm-project#128)
## Summary - parse `parallel_tool_calls` on `/v1/responses` requests - forward the field through `RequestPayload::to_upstream_request` after gateway tool normalization - add unit and route-level regression coverage proving `parallel_tool_calls: false` reaches the mock vLLM request Closes vllm-project#127. ## Why The gateway already preserves several Responses API generation/control fields (`include`, `temperature`, `top_p`, `max_output_tokens`, `truncation`, `metadata`) while normalizing tools for vLLM. `parallel_tool_calls` was missing from the typed request model, so clients that explicitly disabled parallel tool calls had that setting silently dropped on the executor path. ## Validation - `cargo test -p agentic-server-core` - `cargo test -p agentic-server --test responses_test` - `cargo test --workspace` - `cargo clippy --workspace --all-targets -- -D warnings` - `cargo fmt -- --check` Focused tests were written first and failed before the implementation because `parallel_tool_calls` serialized as absent/null instead of `false`. --------- Signed-off-by: harivilasp <harivilasp@gmail.com>
1 parent 384c54c commit 8d74b4c

9 files changed

Lines changed: 232 additions & 1 deletion

File tree

crates/agentic-server-core/benches/executor_throughput.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ fn make_request(input: &str, stream: bool, prev_id: Option<String>) -> RequestPa
145145
max_output_tokens: None,
146146
truncation: None,
147147
metadata: None,
148+
parallel_tool_calls: None,
148149
cache_salt: None,
149150
}
150151
}

crates/agentic-server-core/src/executor/modes/conversation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ mod tests {
141141
max_output_tokens: None,
142142
truncation: None,
143143
metadata: None,
144+
parallel_tool_calls: None,
144145
cache_salt: None,
145146
};
146147
RequestContext {

crates/agentic-server-core/src/executor/modes/response.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ mod tests {
120120
max_output_tokens: None,
121121
truncation: None,
122122
metadata: None,
123+
parallel_tool_calls: None,
123124
cache_salt: None,
124125
};
125126
RequestContext {

crates/agentic-server-core/src/tool/normalize.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,33 @@ use crate::utils::common::serialize_to_value_or_custom_default;
66
use super::codex::CodexNamespaceHandler;
77
use super::function::FunctionHandler;
88
use super::handler::{ToolHandler, ToolOutput};
9-
use super::mcp::McpHandler;
9+
use super::mcp::{McpHandler, maybe_mcp_function};
10+
use super::registry::ToolType;
1011
use super::web_search::web_search_function_tool;
1112

1213
impl ResponsesTool {
14+
/// Return the gateway routing type this declaration would register as.
15+
#[must_use]
16+
pub fn tool_type(&self) -> Option<ToolType> {
17+
match self {
18+
Self::Function(p) => match maybe_mcp_function(p) {
19+
Some(params) if !params.is_empty() => Some(ToolType::Mcp),
20+
_ => Some(ToolType::Function),
21+
},
22+
Self::Mcp(_) => Some(ToolType::Mcp),
23+
Self::WebSearch(_) => Some(ToolType::WebSearch),
24+
Self::FileSearch(_) => Some(ToolType::FileSearch),
25+
Self::CodeInterpreter(_) => Some(ToolType::CodeInterpreter),
26+
Self::Namespace(_) => Some(ToolType::CodexNamespace),
27+
Self::Custom(_) | Self::Unknown => None,
28+
}
29+
}
30+
31+
#[must_use]
32+
pub fn is_gateway_owned(&self) -> bool {
33+
self.tool_type().is_some_and(ToolType::is_gateway_owned)
34+
}
35+
1336
/// Normalise function-like tool declarations to the `FunctionTool` wire format that vLLM understands.
1437
///
1538
/// - `Function` variants convert via [`From<&FunctionToolParam>`] for `FunctionTool`.

crates/agentic-server-core/src/types/request_response.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub struct RequestPayload {
2828
pub max_output_tokens: Option<u32>,
2929
pub truncation: Option<String>,
3030
pub metadata: Option<Value>,
31+
pub parallel_tool_calls: Option<bool>,
3132
#[serde(default, skip_serializing_if = "Option::is_none")]
3233
pub cache_salt: Option<String>,
3334
}
@@ -64,6 +65,7 @@ pub struct UpstreamRequest<'a> {
6465
#[serde(skip_serializing_if = "Option::is_none")]
6566
pub metadata: Option<&'a Value>,
6667
#[serde(skip_serializing_if = "Option::is_none")]
68+
pub parallel_tool_calls: Option<bool>,
6769
pub cache_salt: Option<&'a str>,
6870
}
6971

@@ -128,6 +130,18 @@ impl RequestPayload {
128130
/// flat name collides with a top-level function tool or another namespace
129131
/// member.
130132
pub fn to_upstream_request(&self, stream: bool) -> Result<UpstreamRequest<'_>, ToolError> {
133+
let has_built_in_tool = self.declares_built_in_tool();
134+
if has_built_in_tool && self.parallel_tool_calls == Some(true) {
135+
return Err(ToolError::Config(
136+
"parallel_tool_calls must be false when using built-in tools".into(),
137+
));
138+
}
139+
let parallel_tool_calls = if has_built_in_tool {
140+
Some(false)
141+
} else {
142+
self.parallel_tool_calls
143+
};
144+
131145
let renamed_tools = self
132146
.tools
133147
.as_deref()
@@ -151,9 +165,16 @@ impl RequestPayload {
151165
max_output_tokens: self.max_output_tokens,
152166
truncation: self.truncation.as_deref(),
153167
metadata: self.metadata.as_ref(),
168+
parallel_tool_calls,
154169
cache_salt: self.cache_salt.as_deref(),
155170
})
156171
}
172+
173+
fn declares_built_in_tool(&self) -> bool {
174+
self.tools
175+
.as_deref()
176+
.is_some_and(|tools| tools.iter().any(ResponsesTool::is_gateway_owned))
177+
}
157178
}
158179

159180
fn upstream_tools(tool: ResponsesTool) -> Vec<UpstreamTool> {
@@ -302,6 +323,144 @@ mod tests {
302323
assert_eq!(value["input"], "hi");
303324
}
304325

326+
#[test]
327+
fn to_upstream_request_preserves_parallel_tool_calls() {
328+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
329+
"model": "test",
330+
"input": "hi",
331+
"parallel_tool_calls": false
332+
}))
333+
.unwrap();
334+
335+
let upstream = payload.to_upstream_request(false).expect("valid upstream request");
336+
let value = serde_json::to_value(upstream).unwrap();
337+
assert_eq!(value["parallel_tool_calls"], false);
338+
}
339+
340+
#[test]
341+
fn to_upstream_request_allows_parallel_tool_calls_for_client_function_tools() {
342+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
343+
"model": "test",
344+
"input": "hi",
345+
"parallel_tool_calls": true,
346+
"tools": [{"type": "function", "name": "get_weather"}]
347+
}))
348+
.unwrap();
349+
350+
let upstream = payload
351+
.to_upstream_request(false)
352+
.expect("function tools allow parallel calls");
353+
let value = serde_json::to_value(upstream).unwrap();
354+
assert_eq!(value["parallel_tool_calls"], true);
355+
}
356+
357+
#[test]
358+
fn to_upstream_request_validates_parallel_tool_calls_for_mixed_tools() {
359+
for built_in_tool in builtin_tool_declarations() {
360+
for (parallel_tool_calls, should_reject) in [(false, false), (true, true)] {
361+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
362+
"model": "test",
363+
"input": "hi",
364+
"parallel_tool_calls": parallel_tool_calls,
365+
"tools": [
366+
{"type": "function", "name": "get_weather"},
367+
built_in_tool.clone()
368+
]
369+
}))
370+
.unwrap();
371+
372+
let result = payload.to_upstream_request(false);
373+
if should_reject {
374+
let err = result.expect_err("built-in tools should reject parallel tool calls");
375+
assert!(err.to_string().contains("parallel_tool_calls must be false"));
376+
} else {
377+
let value =
378+
serde_json::to_value(result.expect("mixed built-in and function tools allow serial calls"))
379+
.unwrap();
380+
assert_eq!(value["parallel_tool_calls"], false);
381+
}
382+
}
383+
}
384+
}
385+
386+
#[test]
387+
fn to_upstream_request_sets_serial_tool_calls_for_builtin_tools() {
388+
for tool in builtin_tool_declarations() {
389+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
390+
"model": "test",
391+
"input": "hi",
392+
"tools": [tool]
393+
}))
394+
.unwrap();
395+
396+
let upstream = payload
397+
.to_upstream_request(false)
398+
.expect("built-in tools default to serial tool calls");
399+
let value = serde_json::to_value(upstream).unwrap();
400+
assert_eq!(value["parallel_tool_calls"], false);
401+
}
402+
}
403+
404+
#[test]
405+
fn to_upstream_request_rejects_parallel_tool_calls_for_builtin_tools() {
406+
for tool in builtin_tool_declarations() {
407+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
408+
"model": "test",
409+
"input": "hi",
410+
"parallel_tool_calls": true,
411+
"tools": [tool]
412+
}))
413+
.unwrap();
414+
415+
let Err(err) = payload.to_upstream_request(false) else {
416+
panic!("built-in tools should reject parallel_tool_calls=true");
417+
};
418+
419+
assert!(err.to_string().contains("parallel_tool_calls must be false"));
420+
}
421+
}
422+
423+
#[test]
424+
fn to_upstream_request_allows_builtin_tools_with_serial_tool_calls() {
425+
for tool in builtin_tool_declarations() {
426+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
427+
"model": "test",
428+
"input": "hi",
429+
"parallel_tool_calls": false,
430+
"tools": [tool]
431+
}))
432+
.unwrap();
433+
434+
let upstream = payload
435+
.to_upstream_request(false)
436+
.expect("serial built-in tool request is valid");
437+
let value = serde_json::to_value(upstream).unwrap();
438+
assert_eq!(value["parallel_tool_calls"], false);
439+
}
440+
}
441+
442+
fn builtin_tool_declarations() -> Vec<Value> {
443+
vec![
444+
serde_json::json!({
445+
"type": "function",
446+
"name": "read_mcp_resource",
447+
"metadata": {
448+
"server_label": "repo",
449+
"server_url": "http://localhost:9001/mcp"
450+
}
451+
}),
452+
serde_json::json!({
453+
"type": "mcp",
454+
"name": "read_mcp_resource",
455+
"server_label": "repo",
456+
"server_url": "http://localhost:9001/mcp"
457+
}),
458+
serde_json::json!({"type": "web_search_preview"}),
459+
serde_json::json!({"type": "file_search", "vector_store_ids": ["vs_abc"]}),
460+
serde_json::json!({"type": "code_interpreter"}),
461+
]
462+
}
463+
305464
#[test]
306465
fn to_upstream_request_flattens_namespace_and_skips_unknown_tools() {
307466
let payload: RequestPayload = serde_json::from_value(serde_json::json!({

crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ fn request(text: &str, tools: Option<Vec<ResponsesTool>>) -> RequestPayload {
127127
max_output_tokens: Some(1024),
128128
truncation: None,
129129
metadata: None,
130+
parallel_tool_calls: None,
130131
cache_salt: None,
131132
}
132133
}

crates/agentic-server-core/tests/support/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ pub fn make_request(
369369
max_output_tokens: None,
370370
truncation: None,
371371
metadata: None,
372+
parallel_tool_calls: None,
372373
cache_salt: None,
373374
}
374375
}

crates/agentic-server-core/tests/web_search_tool_test.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ async fn execute_runs_web_search_and_sends_tool_output_back_to_model() {
584584
max_output_tokens: Some(1024),
585585
truncation: None,
586586
metadata: None,
587+
parallel_tool_calls: None,
587588
cache_salt: None,
588589
};
589590

@@ -668,6 +669,7 @@ async fn execute_relaxes_forced_tool_choice_after_web_search_result() {
668669
max_output_tokens: Some(1024),
669670
truncation: None,
670671
metadata: None,
672+
parallel_tool_calls: None,
671673
cache_salt: None,
672674
};
673675

@@ -716,6 +718,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request(
716718
max_output_tokens: Some(1024),
717719
truncation: None,
718720
metadata: None,
721+
parallel_tool_calls: None,
719722
cache_salt: None,
720723
};
721724

@@ -763,6 +766,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request(
763766
max_output_tokens: Some(1024),
764767
truncation: None,
765768
metadata: None,
769+
parallel_tool_calls: None,
766770
cache_salt: None,
767771
};
768772
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() {
833837
max_output_tokens: Some(1024),
834838
truncation: None,
835839
metadata: None,
840+
parallel_tool_calls: None,
836841
cache_salt: None,
837842
};
838843

@@ -876,6 +881,7 @@ async fn stream_emits_web_search_lifecycle_events_before_final_payload() {
876881
max_output_tokens: Some(1024),
877882
truncation: None,
878883
metadata: None,
884+
parallel_tool_calls: None,
879885
cache_salt: None,
880886
};
881887

@@ -958,6 +964,7 @@ async fn stream_hides_web_search_function_events_when_name_arrives_on_done() {
958964
max_output_tokens: Some(1024),
959965
truncation: None,
960966
metadata: None,
967+
parallel_tool_calls: None,
961968
cache_salt: None,
962969
};
963970

@@ -1024,6 +1031,7 @@ async fn execute_runs_multiple_web_search_calls_concurrently() {
10241031
max_output_tokens: Some(1024),
10251032
truncation: None,
10261033
metadata: None,
1034+
parallel_tool_calls: None,
10271035
cache_salt: None,
10281036
};
10291037

@@ -1072,6 +1080,7 @@ async fn execute_feeds_web_search_execution_errors_back_to_model() {
10721080
max_output_tokens: Some(1024),
10731081
truncation: None,
10741082
metadata: None,
1083+
parallel_tool_calls: None,
10751084
cache_salt: None,
10761085
};
10771086

@@ -1122,6 +1131,7 @@ async fn execute_returns_incomplete_after_max_gateway_tool_rounds() {
11221131
max_output_tokens: Some(1024),
11231132
truncation: None,
11241133
metadata: None,
1134+
parallel_tool_calls: None,
11251135
cache_salt: None,
11261136
};
11271137

@@ -1172,6 +1182,7 @@ async fn execute_feeds_invalid_web_search_arguments_back_to_model() {
11721182
max_output_tokens: Some(1024),
11731183
truncation: None,
11741184
metadata: None,
1185+
parallel_tool_calls: None,
11751186
cache_salt: None,
11761187
};
11771188

@@ -1229,6 +1240,7 @@ async fn execute_runs_large_gateway_fanout_without_hard_cap() {
12291240
max_output_tokens: Some(1024),
12301241
truncation: None,
12311242
metadata: None,
1243+
parallel_tool_calls: None,
12321244
cache_salt: None,
12331245
};
12341246

@@ -1292,6 +1304,7 @@ async fn stream_error_events_escape_error_messages() {
12921304
max_output_tokens: Some(1024),
12931305
truncation: None,
12941306
metadata: None,
1307+
parallel_tool_calls: None,
12951308
cache_salt: None,
12961309
};
12971310

@@ -1367,6 +1380,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() {
13671380
max_output_tokens: Some(1024),
13681381
truncation: None,
13691382
metadata: None,
1383+
parallel_tool_calls: None,
13701384
cache_salt: None,
13711385
};
13721386

@@ -1393,6 +1407,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() {
13931407
max_output_tokens: Some(1024),
13941408
truncation: None,
13951409
metadata: None,
1410+
parallel_tool_calls: None,
13961411
cache_salt: None,
13971412
};
13981413
let _ = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap();
@@ -1463,6 +1478,7 @@ async fn stream_returns_incomplete_after_max_gateway_tool_rounds() {
14631478
max_output_tokens: Some(1024),
14641479
truncation: None,
14651480
metadata: None,
1481+
parallel_tool_calls: None,
14661482
cache_salt: None,
14671483
};
14681484

0 commit comments

Comments
 (0)