Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ fn make_request(input: &str, stream: bool, prev_id: Option<String>) -> RequestPa
max_output_tokens: None,
truncation: None,
metadata: None,
parallel_tool_calls: None,
cache_salt: None,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ mod tests {
max_output_tokens: None,
truncation: None,
metadata: None,
parallel_tool_calls: None,
cache_salt: None,
};
RequestContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ mod tests {
max_output_tokens: None,
truncation: None,
metadata: None,
parallel_tool_calls: None,
cache_salt: None,
};
RequestContext {
Expand Down
25 changes: 24 additions & 1 deletion crates/agentic-server-core/src/tool/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolType> {
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`.
Expand Down
150 changes: 150 additions & 0 deletions crates/agentic-server-core/src/types/request_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct RequestPayload {
pub max_output_tokens: Option<u32>,
pub truncation: Option<String>,
pub metadata: Option<Value>,
pub parallel_tool_calls: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_salt: Option<String>,
}
Expand Down Expand Up @@ -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<bool>,
pub cache_salt: Option<&'a str>,
}

Expand All @@ -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<UpstreamRequest<'_>, 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()
Expand All @@ -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)]
Expand Down Expand Up @@ -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<Value> {
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!({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ fn request(text: &str, tools: Option<Vec<ResponsesTool>>) -> RequestPayload {
max_output_tokens: Some(1024),
truncation: None,
metadata: None,
parallel_tool_calls: None,
cache_salt: None,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/agentic-server-core/tests/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ pub fn make_request(
max_output_tokens: None,
truncation: None,
metadata: None,
parallel_tool_calls: None,
cache_salt: None,
}
}
Expand Down
16 changes: 16 additions & 0 deletions crates/agentic-server-core/tests/web_search_tool_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand All @@ -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();
Expand Down Expand Up @@ -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,
};

Expand Down
Loading