Flatten namespace tools for providers without wrappers

This commit is contained in:
Mitsuhiro Kotake
2026-07-01 18:00:57 +09:00
parent db887d03e1
commit acef5a0965
35 changed files with 703 additions and 70 deletions
@@ -199,7 +199,10 @@ fn has_model_resume_override(
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
}
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
fn validate_dynamic_tools(
tools: &[DynamicToolSpec],
namespace_tools_enabled: bool,
) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
const DYNAMIC_TOOL_NAMESPACE_DESCRIPTION_MAX_LEN: usize = 1024;
@@ -252,6 +255,8 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
tool: &'a DynamicToolFunctionSpec,
namespace: Option<&str>,
seen: &mut HashSet<&'a str>,
seen_canonical_flat_names: &mut HashSet<String>,
namespace_tools_enabled: bool,
) -> Result<(), String> {
let name = tool.name.trim();
if name.is_empty() {
@@ -275,6 +280,23 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
}
return Err(format!("duplicate dynamic tool name: {name}"));
}
if !namespace_tools_enabled {
let canonical_flat_name =
codex_tools::ToolName::new(namespace.map(str::to_string), name)
.canonical_flat_name()
.into_owned();
validate_dynamic_tool_identifier(
&canonical_flat_name,
"dynamic tool canonical flat name",
DYNAMIC_TOOL_NAME_MAX_LEN,
)?;
if !seen_canonical_flat_names.insert(canonical_flat_name.clone()) {
return Err(format!(
"duplicate dynamic tool canonical flat name: {}",
escape_identifier_for_error(&canonical_flat_name),
));
}
}
if tool.defer_loading && namespace.is_none() {
return Err(format!(
"deferred dynamic tool must include a namespace: {name}"
@@ -291,10 +313,17 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
let mut seen_tools = HashSet::new();
let mut seen_namespaces = HashSet::new();
let mut seen_canonical_flat_names = HashSet::new();
for spec in tools {
match spec {
DynamicToolSpec::Function(tool) => {
validate_dynamic_tool(tool, /*namespace*/ None, &mut seen_tools)?;
validate_dynamic_tool(
tool,
/*namespace*/ None,
&mut seen_tools,
&mut seen_canonical_flat_names,
namespace_tools_enabled,
)?;
}
DynamicToolSpec::Namespace(namespace) => {
let name = namespace.name.trim();
@@ -338,7 +367,13 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
let mut seen_namespace_tools = HashSet::new();
for tool in &namespace.tools {
let DynamicToolNamespaceTool::Function(tool) = tool;
validate_dynamic_tool(tool, Some(name), &mut seen_namespace_tools)?;
validate_dynamic_tool(
tool,
Some(name),
&mut seen_namespace_tools,
&mut seen_canonical_flat_names,
namespace_tools_enabled,
)?;
}
}
}
@@ -1146,7 +1181,12 @@ impl ThreadRequestProcessor {
});
let dynamic_tools = dynamic_tools.unwrap_or_default();
if !dynamic_tools.is_empty() {
validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?;
let namespace_tools_enabled =
create_model_provider(config.model_provider.clone(), /*auth_manager*/ None)
.capabilities()
.namespace_tools;
validate_dynamic_tools(&dynamic_tools, namespace_tools_enabled)
.map_err(invalid_request)?;
}
// Count callable functions rather than top-level namespace containers.
let dynamic_tool_count: usize = dynamic_tools
@@ -181,7 +181,8 @@ mod thread_processor_behavior_tests {
json!({"type": "null"}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
@@ -194,7 +195,7 @@ mod thread_processor_behavior_tests {
json!({"properties": {}}),
/*defer_loading*/ false,
)];
validate_dynamic_tools(&tools).expect("valid schema");
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
}
#[test]
@@ -212,7 +213,7 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ false,
)];
validate_dynamic_tools(&tools).expect("valid schema");
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
}
#[test]
@@ -239,7 +240,67 @@ mod thread_processor_behavior_tests {
/*defer_loading*/ true,
),
];
validate_dynamic_tools(&tools).expect("valid schema");
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_rejects_flat_name_collisions_without_namespace_tools() {
let tools = vec![
dynamic_tool(
Some("a"),
"b__c",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
/*defer_loading*/ true,
),
dynamic_tool(
Some("a__b"),
"c",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
/*defer_loading*/ true,
),
];
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ false)
.expect_err("flat name collision");
assert!(err.contains("a__b__c"), "unexpected error: {err}");
assert!(
err.contains("canonical flat name"),
"unexpected error: {err}"
);
}
#[test]
fn validate_dynamic_tools_rejects_overlong_flat_names_without_namespace_tools() {
let namespace = "a".repeat(64);
let name = "b".repeat(63);
let tools = vec![dynamic_tool(
Some(&namespace),
name,
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ false)
.expect_err("flat name too long");
assert!(
err.contains("canonical flat name"),
"unexpected error: {err}"
);
assert!(err.contains("at most 128"), "unexpected error: {err}");
}
#[test]
@@ -254,7 +315,7 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ true,
)];
validate_dynamic_tools(&tools).expect("valid schema");
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
}
#[test]
@@ -279,7 +340,8 @@ mod thread_processor_behavior_tests {
],
},
)];
let err = validate_dynamic_tools(&tools).expect_err("duplicate name");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("duplicate name");
assert!(err.contains("codex_app"), "unexpected error: {err}");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
@@ -336,7 +398,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("empty namespace");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("empty namespace");
assert!(err.contains("namespace"), "unexpected error: {err}");
}
@@ -352,7 +415,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("reserved namespace");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("reserved namespace");
assert!(err.contains("reserved"), "unexpected error: {err}");
}
@@ -368,7 +432,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("invalid name");
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
@@ -388,7 +453,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("invalid namespace");
assert!(err.contains("codex.app"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
@@ -409,7 +475,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("name too long");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("name too long");
assert!(err.contains("at most 128"), "unexpected error: {err}");
assert!(err.contains(&long_name), "unexpected error: {err}");
}
@@ -427,7 +494,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("namespace too long");
assert!(err.contains("at most 64"), "unexpected error: {err}");
assert!(err.contains(&long_namespace), "unexpected error: {err}");
@@ -436,7 +504,8 @@ mod thread_processor_behavior_tests {
};
namespace.name = "tickets".to_string();
namespace.description = "a".repeat(1025);
let err = validate_dynamic_tools(&tools).expect_err("namespace description too long");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("namespace description too long");
assert!(err.contains("at most 1024"), "unexpected error: {err}");
}
@@ -452,7 +521,8 @@ mod thread_processor_behavior_tests {
}),
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
.expect_err("reserved Responses namespace");
assert!(err.contains("functions"), "unexpected error: {err}");
assert!(err.contains("Responses API"), "unexpected error: {err}");
}
@@ -686,6 +756,7 @@ mod thread_processor_behavior_tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: None,
};
let config_manager = ConfigManager::new(
temp_dir.path().to_path_buf(),
@@ -45,6 +45,7 @@ pub fn write_mock_responses_config_toml(
name = "{provider_name}"
base_url = "{server_uri}/v1"
wire_api = "responses"
namespace_tools = true
request_max_retries = 0
stream_max_retries = 0
supports_websockets = false
@@ -927,6 +927,7 @@ model_provider = "mock_provider"
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
namespace_tools = true
request_max_retries = 0
stream_max_retries = 0
"#
@@ -678,6 +678,7 @@ include_instructions = true
name = "Mock provider for test"
base_url = "{responses_server_uri}/v1"
wire_api = "responses"
namespace_tools = true
request_max_retries = 0
stream_max_retries = 0
"#
@@ -663,6 +663,25 @@ fn test_normalize_tools_disambiguates_sanitized_tool_name_collisions() {
assert_eq!(callable_tool_names.len(), 2);
}
#[test]
fn test_normalize_tools_disambiguates_canonical_flat_name_collisions() {
let tools = vec![create_test_tool("a", "b__c"), create_test_tool("a__b", "c")];
let model_tools =
normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true);
assert_eq!(model_tools.len(), 2);
let canonical_flat_names = model_tools
.iter()
.map(|tool| {
tool.canonical_tool_name()
.canonical_flat_name()
.into_owned()
})
.collect::<HashSet<_>>();
assert_eq!(canonical_flat_names.len(), 2);
}
#[test]
fn tool_filter_allows_by_default() {
let filter = ToolFilter::default();
+9 -3
View File
@@ -376,8 +376,8 @@ fn unique_callable_parts(
used_names: &mut HashSet<String>,
reserved_len: usize,
) -> (String, String) {
let model_name = format!("{namespace}{tool_name}");
if model_name.len() + reserved_len <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) {
let model_name = canonical_flat_tool_name(namespace, tool_name);
if model_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) {
return (namespace.to_string(), tool_name.to_string());
}
@@ -390,10 +390,16 @@ fn unique_callable_parts(
};
let (namespace, tool_name) =
fit_callable_parts_with_hash(namespace, tool_name, &hash_input, reserved_len);
let model_name = format!("{namespace}{tool_name}");
let model_name = canonical_flat_tool_name(&namespace, &tool_name);
if used_names.insert(model_name) {
return (namespace, tool_name);
}
attempt = attempt.saturating_add(1);
}
}
fn canonical_flat_tool_name(namespace: &str, tool_name: &str) -> String {
ToolName::namespaced(namespace, tool_name)
.canonical_flat_name()
.into_owned()
}
+1
View File
@@ -316,6 +316,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: None,
}
}
}
@@ -48,6 +48,7 @@ message ModelProvider {
optional uint64 websocket_connect_timeout_ms = 15;
bool requires_openai_auth = 16;
bool supports_websockets = 17;
optional bool namespace_tools = 18;
}
message StringMap {
@@ -75,6 +75,8 @@ pub struct ModelProvider {
pub requires_openai_auth: bool,
#[prost(bool, tag = "17")]
pub supports_websockets: bool,
#[prost(bool, optional, tag = "18")]
pub namespace_tools: ::core::option::Option<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct StringMap {
@@ -190,6 +190,7 @@ fn model_provider_from_proto(
websocket_connect_timeout_ms: provider.websocket_connect_timeout_ms,
requires_openai_auth: provider.requires_openai_auth,
supports_websockets: provider.supports_websockets,
namespace_tools: provider.namespace_tools,
};
Ok((id, info))
}
@@ -217,6 +218,7 @@ fn model_provider_to_proto(
websocket_connect_timeout_ms,
requires_openai_auth,
supports_websockets,
namespace_tools,
} = provider;
proto::ModelProvider {
@@ -237,6 +239,7 @@ fn model_provider_to_proto(
websocket_connect_timeout_ms,
requires_openai_auth,
supports_websockets,
namespace_tools,
}
}
@@ -473,6 +476,7 @@ mod tests {
websocket_connect_timeout_ms: Some(10_000),
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: Some(false),
}],
features: HashMap::from([
("plugins".to_string(), false),
@@ -537,6 +541,7 @@ mod tests {
requires_openai_auth: false,
supports_websockets: true,
aws: None,
namespace_tools: Some(false),
}
}
+5
View File
@@ -1665,6 +1665,11 @@
"description": "Friendly display name.",
"type": "string"
},
"namespace_tools": {
"default": null,
"description": "Whether this provider accepts Codex's `type: \"namespace\"` tool wrapper.",
"type": "boolean"
},
"query_params": {
"additionalProperties": {
"type": "string"
+1
View File
@@ -276,6 +276,7 @@ fn should_use_remote_compact_task_for_azure_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
assert!(should_use_remote_compact_task(&provider));
+25 -1
View File
@@ -771,6 +771,30 @@ region = "us-west-2"
);
}
#[tokio::test]
async fn load_config_applies_amazon_bedrock_namespace_tools_override() {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "amazon-bedrock"
[model_providers.amazon-bedrock]
namespace_tools = false
"#,
)
.expect("Amazon Bedrock namespace tool override should deserialize");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert_eq!(config.model_provider_id, "amazon-bedrock");
assert_eq!(config.model_provider.namespace_tools, Some(false));
}
#[tokio::test]
async fn load_config_rejects_unsupported_amazon_bedrock_overrides() {
let cfg = toml::from_str::<ConfigToml>(
@@ -800,7 +824,7 @@ region = "us-west-2"
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains(
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
"model_providers.amazon-bedrock only supports changing `aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
));
}
+58 -10
View File
@@ -11,19 +11,21 @@ use bm25::Language;
use bm25::SearchEngine;
use bm25::SearchEngineBuilder;
use codex_tools::LoadableToolSpec;
use codex_tools::NamespaceToolSpecMode;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::ToolSearchEntry;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use codex_tools::coalesce_loadable_tool_specs;
use codex_tools::serialize_loadable_tool_specs;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::instrument;
pub struct ToolSearchHandler {
search_infos: Vec<ToolSearchInfo>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
spec: ToolSpec,
search_engine: SearchEngine<usize>,
}
@@ -35,20 +37,29 @@ pub(crate) struct ToolSearchHandlerCache {
impl ToolSearchHandlerCache {
#[instrument(level = "trace", skip_all, fields(search_info_count = search_infos.len()))]
pub(crate) fn get_or_build(&self, search_infos: Vec<ToolSearchInfo>) -> Arc<ToolSearchHandler> {
pub(crate) fn get_or_build(
&self,
search_infos: Vec<ToolSearchInfo>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
) -> Arc<ToolSearchHandler> {
{
let cached = self.cached();
if let Some(cached) = cached.as_ref()
&& cached.search_infos == search_infos
&& cached.namespace_tool_spec_mode == namespace_tool_spec_mode
{
return Arc::clone(cached);
}
}
let handler = Arc::new(ToolSearchHandler::new(search_infos));
let handler = Arc::new(ToolSearchHandler::new(
search_infos,
namespace_tool_spec_mode,
));
let mut cached = self.cached();
if let Some(cached) = cached.as_ref()
&& cached.search_infos == handler.search_infos
&& cached.namespace_tool_spec_mode == handler.namespace_tool_spec_mode
{
return Arc::clone(cached);
}
@@ -71,7 +82,10 @@ impl ToolSearchHandler {
skip_all,
fields(search_info_count = search_infos.len())
)]
pub(crate) fn new(search_infos: Vec<ToolSearchInfo>) -> Self {
pub(crate) fn new(
search_infos: Vec<ToolSearchInfo>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
) -> Self {
let search_source_infos = search_infos
.iter()
.filter_map(|search_info| search_info.source_info.clone())
@@ -88,6 +102,7 @@ impl ToolSearchHandler {
Self {
search_infos,
namespace_tool_spec_mode,
spec,
search_engine,
}
@@ -174,8 +189,9 @@ impl ToolSearchHandler {
&self,
results: impl IntoIterator<Item = &'a ToolSearchEntry>,
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
Ok(coalesce_loadable_tool_specs(
Ok(serialize_loadable_tool_specs(
results.into_iter().map(|entry| entry.output.clone()),
self.namespace_tool_spec_mode,
))
}
}
@@ -205,17 +221,20 @@ mod tests {
.expect("MCP handler should return search info"),
];
let first = cache.get_or_build(search_infos.clone());
let second = cache.get_or_build(search_infos.clone());
let first = cache.get_or_build(search_infos.clone(), NamespaceToolSpecMode::Preserve);
let second = cache.get_or_build(search_infos.clone(), NamespaceToolSpecMode::Preserve);
assert!(Arc::ptr_eq(&first, &second));
let mut changed_search_infos = search_infos;
let mut changed_search_infos = search_infos.clone();
changed_search_infos[0]
.entry
.search_text
.push_str(" changed");
let changed = cache.get_or_build(changed_search_infos);
let changed = cache.get_or_build(changed_search_infos, NamespaceToolSpecMode::Preserve);
assert!(!Arc::ptr_eq(&first, &changed));
let flattened = cache.get_or_build(search_infos, NamespaceToolSpecMode::Flatten);
assert!(!Arc::ptr_eq(&first, &flattened));
}
#[test]
@@ -257,7 +276,7 @@ mod tests {
.search_info()
.expect("dynamic handler should return search info")
}));
let handler = ToolSearchHandler::new(search_infos);
let handler = ToolSearchHandler::new(search_infos, NamespaceToolSpecMode::Preserve);
let results = [
&handler.search_infos[0].entry,
&handler.search_infos[2].entry,
@@ -325,6 +344,35 @@ mod tests {
);
}
#[test]
fn search_results_flatten_mcp_namespaces_without_namespace_tools() {
let search_info = McpHandler::new(tool_info("calendar", "create_event", "Create events"))
.expect("MCP tool should convert")
.search_info()
.expect("MCP handler should return search info");
let handler = ToolSearchHandler::new(vec![search_info], NamespaceToolSpecMode::Flatten);
let tools = handler
.search_output_tools([&handler.search_infos[0].entry])
.expect("MCP search output should serialize");
assert_eq!(
tools,
vec![LoadableToolSpec::Function(ResponsesApiTool {
name: "mcp__calendar__create_event".to_string(),
description: "Create events desktop tool".to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
Default::default(),
/*required*/ None,
Some(false.into()),
),
output_schema: None,
})],
);
}
fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),
+55 -6
View File
@@ -29,6 +29,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_rollout::state_db;
use codex_tools::NamespaceToolSpecMode;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
@@ -321,15 +322,28 @@ impl CoreToolRuntime for ExposureOverride {
pub struct ToolRegistry {
tools: HashMap<ToolName, Arc<dyn CoreToolRuntime>>,
flat_tool_aliases: HashMap<ToolName, Arc<dyn CoreToolRuntime>>,
}
impl ToolRegistry {
fn new(tools: HashMap<ToolName, Arc<dyn CoreToolRuntime>>) -> Self {
Self { tools }
Self {
tools,
flat_tool_aliases: HashMap::new(),
}
}
#[cfg(test)]
#[instrument(level = "trace", skip_all)]
pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
Self::from_tools_with_namespace_tool_spec_mode(tools, NamespaceToolSpecMode::Preserve)
}
#[instrument(level = "trace", skip_all)]
pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
pub(crate) fn from_tools_with_namespace_tool_spec_mode(
tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
) -> Self {
let mut tools_by_name = HashMap::new();
for tool in tools {
let name = tool.tool_name();
@@ -339,7 +353,12 @@ impl ToolRegistry {
}
tools_by_name.insert(name, tool);
}
Self::new(tools_by_name)
let mut registry = Self::new(tools_by_name);
if namespace_tool_spec_mode == NamespaceToolSpecMode::Flatten {
registry.add_flat_tool_aliases();
}
registry
}
#[cfg(test)]
@@ -357,7 +376,34 @@ impl ToolRegistry {
}
fn tool(&self, name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
self.tools.get(name).map(Arc::clone)
self.resolved_tool(name).map(|(_, tool)| tool)
}
fn resolved_tool(&self, name: &ToolName) -> Option<(ToolName, Arc<dyn CoreToolRuntime>)> {
self.tools
.get(name)
.map(|tool| (name.clone(), Arc::clone(tool)))
.or_else(|| {
self.flat_tool_aliases
.get(name)
.map(|tool| (tool.tool_name(), Arc::clone(tool)))
})
}
fn add_flat_tool_aliases(&mut self) {
for (name, tool) in &self.tools {
if name.namespace.is_none() {
continue;
}
let flat_alias = ToolName::plain(name.canonical_flat_name().into_owned());
if self.tools.contains_key(&flat_alias)
|| self.flat_tool_aliases.contains_key(&flat_alias)
{
error_or_panic(format!("tool {flat_alias} already registered"));
continue;
}
self.flat_tool_aliases.insert(flat_alias, Arc::clone(tool));
}
}
#[cfg(test)]
@@ -439,8 +485,11 @@ impl ToolRegistry {
}
let dispatch_trace = ToolDispatchTrace::start(&invocation);
let tool = match self.tool(&tool_name) {
Some(tool) => tool,
let tool = match self.resolved_tool(&tool_name) {
Some((resolved_tool_name, tool)) => {
invocation.tool_name = resolved_tool_name;
tool
}
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
let log_payload = invocation.payload.log_payload();
+25
View File
@@ -179,6 +179,31 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
);
}
#[test]
fn handler_resolves_flat_aliases_when_namespace_specs_are_flattened() {
let namespaced_name = codex_tools::ToolName::namespaced("mcp__calendar", "create_event");
let handler = Arc::new(TestHandler {
tool_name: namespaced_name.clone(),
}) as Arc<dyn CoreToolRuntime>;
let registry = ToolRegistry::from_tools_with_namespace_tool_spec_mode(
[handler.clone()],
codex_tools::NamespaceToolSpecMode::Flatten,
);
let flat_name = codex_tools::ToolName::plain("mcp__calendar__create_event");
let flat = registry.tool(&flat_name);
let resolved = registry.resolved_tool(&flat_name);
assert!(
flat.as_ref()
.is_some_and(|flat| Arc::ptr_eq(flat, &handler))
);
assert_eq!(
resolved.map(|(tool_name, _)| tool_name),
Some(namespaced_name)
);
}
#[tokio::test]
async fn function_tools_expose_default_hook_payloads_and_rewrites() -> anyhow::Result<()> {
let (session, turn) = crate::session::tests::make_session_and_context().await;
+47 -20
View File
@@ -60,6 +60,7 @@ use crate::tools::registry::ToolRegistry;
use crate::tools::registry::override_tool_exposure;
use crate::tools::router::ToolRouter;
use crate::tools::router::ToolRouterParams;
use crate::util::error_or_panic;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_mcp::ToolInfo;
@@ -72,6 +73,7 @@ use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::NamespaceToolSpecMode;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
@@ -87,6 +89,7 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::default_namespace_description;
use codex_tools::request_user_input_available_modes;
use codex_tools::serialize_tool_specs;
use codex_tools::shell_command_backend_for_features;
use codex_tools::shell_type_for_model_and_features;
use std::collections::BTreeMap;
@@ -261,13 +264,13 @@ fn build_model_visible_specs_and_registry(
}
specs.extend(hosted_specs);
let registry = ToolRegistry::from_tools(runtimes);
let model_visible_specs = merge_into_namespaces(specs)
.into_iter()
.filter(|spec| {
namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_))
})
.collect();
let namespace_tool_spec_mode = namespace_tool_spec_mode(turn_context);
let registry =
ToolRegistry::from_tools_with_namespace_tool_spec_mode(runtimes, namespace_tool_spec_mode);
let model_visible_specs = dedupe_model_visible_function_names(serialize_tool_specs(
merge_into_namespaces(specs),
namespace_tool_spec_mode,
));
(model_visible_specs, registry)
}
@@ -290,6 +293,23 @@ fn spec_for_model_request(
}
}
fn dedupe_model_visible_function_names(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
let mut seen_function_names = HashSet::new();
specs
.into_iter()
.filter(|spec| {
let ToolSpec::Function(tool) = spec else {
return true;
};
if seen_function_names.insert(tool.name.clone()) {
return true;
}
error_or_panic(format!("tool {} already exposed to the model", tool.name));
false
})
.collect()
}
#[instrument(level = "trace", skip_all)]
fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
let turn_context = context.step_context.turn.as_ref();
@@ -329,7 +349,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
}
pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool {
turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context)
turn_context.model_info.supports_search_tool
}
pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool {
@@ -343,6 +363,14 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.provider.capabilities().namespace_tools
}
fn namespace_tool_spec_mode(turn_context: &TurnContext) -> NamespaceToolSpecMode {
if namespace_tools_enabled(turn_context) {
NamespaceToolSpecMode::Preserve
} else {
NamespaceToolSpecMode::Flatten
}
}
fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool {
turn_context.multi_agent_version == MultiAgentVersion::V2
}
@@ -403,7 +431,7 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool {
}
fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool {
if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) {
if !image_generation_runtime_enabled(turn_context) {
return false;
}
@@ -624,13 +652,12 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
}
fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool {
namespace_tools_enabled(turn_context)
&& (turn_context.model_info.use_responses_lite
|| turn_context
.config
.features
.get()
.enabled(Feature::StandaloneWebSearch))
turn_context.model_info.use_responses_lite
|| turn_context
.config
.features
.get()
.enabled(Feature::StandaloneWebSearch)
}
fn tool_environment_mode(step_context: &StepContext) -> ToolEnvironmentMode {
@@ -798,9 +825,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
} else {
ToolExposure::Direct
};
let tool_namespace = namespace_tools_enabled(turn_context)
.then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref())
.flatten();
let tool_namespace = turn_context.config.multi_agent_v2.tool_namespace.as_deref();
let agent_type_description =
agent_type_description(turn_context, context.default_agent_type_description);
planned_tools.add_arc(override_tool_exposure(
@@ -980,7 +1005,9 @@ fn append_tool_search_executor(
return;
}
let handler: PlannedRuntime = context.tool_search_handler_cache.get_or_build(search_infos);
let handler: PlannedRuntime = context
.tool_search_handler_cache
.get_or_build(search_infos, namespace_tool_spec_mode(turn_context));
planned_tools.add_arc(handler);
}
@@ -295,6 +295,15 @@ fn use_actor_authorized_provider(turn: &mut TurnContext) {
turn.provider = create_model_provider(provider_info, /*auth_manager*/ None);
}
fn disable_namespace_tools(turn: &mut TurnContext) {
let mut provider_info = turn.config.model_provider.clone();
provider_info.namespace_tools = Some(false);
update_config(turn, |config| {
config.model_provider = provider_info.clone();
});
turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());
}
struct WebRunExtensionTool;
impl ToolExecutor<ExtensionToolCall> for WebRunExtensionTool {
@@ -769,6 +778,17 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
&["lookup".to_string()]
);
let flattened_direct_mcp = probe_with(
disable_namespace_tools,
ToolPlanInputs {
mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct", "lookup")]),
..ToolPlanInputs::default()
},
)
.await;
flattened_direct_mcp.assert_visible_contains(&["mcp__direct__lookup"]);
flattened_direct_mcp.assert_visible_lacks(&["mcp__direct"]);
let searchable_mcp = ToolPlanInputs {
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),
..ToolPlanInputs::default()
@@ -823,6 +843,19 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
"tool_search",
&ToolName::namespaced("mcp__searchable", "lookup").to_string(),
]);
let flattened_searchable_mcp = probe_with(
|turn| {
turn.model_info.supports_search_tool = true;
disable_namespace_tools(turn);
},
ToolPlanInputs {
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),
..ToolPlanInputs::default()
},
)
.await;
flattened_searchable_mcp.assert_visible_contains(&["tool_search"]);
}
#[tokio::test]
+3
View File
@@ -88,6 +88,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
@@ -222,6 +223,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
@@ -337,6 +339,7 @@ async fn responses_respects_model_info_overrides_from_config() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
+4
View File
@@ -1294,6 +1294,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let codex_home = TempDir::new().unwrap();
@@ -2907,6 +2908,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let codex_home = TempDir::new().unwrap();
@@ -3528,6 +3530,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
// Init session
@@ -3617,6 +3620,7 @@ async fn env_var_overrides_loaded_auth() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
// Init session
@@ -2119,6 +2119,7 @@ fn websocket_provider_with_connect_timeout(
websocket_connect_timeout_ms,
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: None,
}
}
@@ -133,6 +133,13 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
Ok(())
}
fn has_function_tool(tools: &[Value], tool_name: &str) -> bool {
tools.iter().any(|tool| {
tool.get("type").and_then(Value::as_str) == Some("function")
&& tool.get("name").and_then(Value::as_str) == Some(tool_name)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_prepares_images() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -288,6 +295,51 @@ async fn responses_lite_exposes_standalone_tools_for_actor_authorized_provider()
let tools = additional_tools(&body)?;
assert!(has_namespaced_tool(tools, "web", "run"));
assert!(has_namespaced_tool(tools, "image_gen", "imagegen"));
assert!(!has_hosted_tool(tools, "web_search"));
assert!(!has_hosted_tool(tools, "image_generation"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_flattens_standalone_tools_without_namespace_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let extensions = responses_extensions(&auth);
let mut builder = test_codex()
.with_auth(auth)
.with_extensions(extensions)
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
configure_image_capable_model(model_info);
})
.with_config(|config| {
configure_responses_tools(config);
config.model_provider.namespace_tools = Some(false);
});
let test = builder.build(&server).await?;
test.submit_turn("Use flat standalone tools").await?;
let body = response_mock.single_request().body_json();
assert!(body.get("tools").is_none());
let tools = additional_tools(&body)?;
assert!(has_function_tool(tools, "web__run"));
assert!(has_function_tool(tools, "image_gen__imagegen"));
assert!(!has_hosted_tool(tools, "web_search"));
assert!(!has_hosted_tool(tools, "image_generation"));
Ok(())
}
@@ -81,6 +81,7 @@ async fn continue_after_stream_error() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let TestCodex { codex, .. } = test_codex()
@@ -64,6 +64,7 @@ async fn retries_on_early_close() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let TestCodex { codex, .. } = test_codex()
+1
View File
@@ -76,6 +76,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let telemetry =
+21 -10
View File
@@ -138,6 +138,9 @@ pub struct ModelProviderInfo {
/// Whether this provider supports the Responses API WebSocket transport.
#[serde(default)]
pub supports_websockets: bool,
/// Whether this provider accepts Codex's `type: "namespace"` tool wrapper.
#[serde(default)]
pub namespace_tools: Option<bool>,
}
/// AWS SigV4 auth configuration for a model provider.
@@ -359,6 +362,7 @@ impl ModelProviderInfo {
websocket_connect_timeout_ms: None,
requires_openai_auth: true,
supports_websockets: true,
namespace_tools: None,
}
}
@@ -389,6 +393,7 @@ impl ModelProviderInfo {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
}
}
@@ -458,7 +463,7 @@ pub fn built_in_model_providers(
///
/// Configured providers extend the built-in set. Built-in providers are not
/// generally overridable, but the built-in Amazon Bedrock provider allows the
/// user to set `aws.profile` and `aws.region`.
/// user to set `aws.profile`, `aws.region`, and `namespace_tools`.
pub fn merge_configured_model_providers(
mut model_providers: HashMap<String, ModelProviderInfo>,
configured_model_providers: HashMap<String, ModelProviderInfo>,
@@ -466,22 +471,27 @@ pub fn merge_configured_model_providers(
for (key, mut provider) in configured_model_providers {
if key == AMAZON_BEDROCK_PROVIDER_ID {
let aws_override = provider.aws.take();
let namespace_tools_override = provider.namespace_tools.take();
if provider != ModelProviderInfo::default() {
return Err(format!(
"model_providers.{AMAZON_BEDROCK_PROVIDER_ID} only supports changing \
`aws.profile` and `aws.region`; other non-default provider fields are not supported"
`aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
));
}
if let Some(aws_override) = aws_override
&& let Some(built_in_provider) = model_providers.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
&& let Some(built_in_aws) = built_in_provider.aws.as_mut()
{
if let Some(profile) = aws_override.profile {
built_in_aws.profile = Some(profile);
if let Some(built_in_provider) = model_providers.get_mut(AMAZON_BEDROCK_PROVIDER_ID) {
if namespace_tools_override.is_some() {
built_in_provider.namespace_tools = namespace_tools_override;
}
if let Some(region) = aws_override.region {
built_in_aws.region = Some(region);
if let Some(aws_override) = aws_override
&& let Some(built_in_aws) = built_in_provider.aws.as_mut()
{
if let Some(profile) = aws_override.profile {
built_in_aws.profile = Some(profile);
}
if let Some(region) = aws_override.region {
built_in_aws.region = Some(region);
}
}
}
} else {
@@ -530,6 +540,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
}
}
@@ -29,6 +29,7 @@ base_url = "http://localhost:11434/v1"
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -63,6 +64,7 @@ query_params = { api-version = "2025-04-01-preview" }
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -100,6 +102,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -168,6 +171,7 @@ fn test_supports_remote_compaction_for_azure_name() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
assert!(provider.supports_remote_compaction());
@@ -193,6 +197,7 @@ fn test_supports_remote_compaction_for_non_openai_non_azure_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
assert!(!provider.supports_remote_compaction());
@@ -301,6 +306,7 @@ fn test_create_amazon_bedrock_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
}
);
}
@@ -405,7 +411,7 @@ fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fiel
configured_model_providers,
),
Err(
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
"model_providers.amazon-bedrock only supports changing `aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
.to_string()
)
);
@@ -106,7 +106,7 @@ impl ModelProvider for AmazonBedrockModelProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
namespace_tools: true,
namespace_tools: self.info.namespace_tools.unwrap_or(true),
image_generation: false,
web_search: false,
}
@@ -292,6 +292,15 @@ mod tests {
);
}
#[test]
fn capabilities_namespace_tools_honors_explicit_opt_out() {
let mut info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
info.namespace_tools = Some(false);
let provider = AmazonBedrockModelProvider::new(info, /*auth_manager*/ None);
assert!(!provider.capabilities().namespace_tools);
}
#[test]
fn approval_review_preferred_model_uses_bedrock_gpt_5_4() {
let provider = AmazonBedrockModelProvider::new(
+48
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use codex_api::ApiError;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_api::is_azure_responses_provider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
@@ -47,6 +48,13 @@ impl Default for ProviderCapabilities {
}
}
fn resolve_namespace_tools(info: &ModelProviderInfo) -> bool {
info.namespace_tools.unwrap_or_else(|| {
info.requires_openai_auth
|| is_azure_responses_provider(&info.name, info.base_url.as_deref())
})
}
/// Current app-visible account state for a model provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountState {
@@ -248,6 +256,13 @@ impl ModelProvider for ConfiguredModelProvider {
&self.info
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
namespace_tools: resolve_namespace_tools(&self.info),
..ProviderCapabilities::default()
}
}
fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
@@ -399,6 +414,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
}
}
@@ -465,6 +481,38 @@ mod tests {
assert_eq!(provider.capabilities(), ProviderCapabilities::default());
}
#[test]
fn configured_non_openai_provider_flattens_namespace_tools() {
let provider = create_model_provider(
provider_for("https://example.test/v1".to_string()),
/*auth_manager*/ None,
);
assert!(!provider.capabilities().namespace_tools);
}
#[test]
fn configured_azure_provider_keeps_namespace_tools() {
let mut azure = provider_for("https://xxxxx.openai.azure.com/openai".to_string());
azure.name = "azure".to_string();
let provider = create_model_provider(azure, /*auth_manager*/ None);
assert!(provider.capabilities().namespace_tools);
}
#[test]
fn configured_provider_namespace_tools_honors_explicit_override() {
let mut non_openai = provider_for("https://example.test/v1".to_string());
non_openai.namespace_tools = Some(true);
let provider = create_model_provider(non_openai, /*auth_manager*/ None);
assert!(provider.capabilities().namespace_tools);
let mut openai = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
openai.namespace_tools = Some(false);
let provider = create_model_provider(openai, /*auth_manager*/ None);
assert!(!provider.capabilities().namespace_tools);
}
#[test]
fn configured_provider_uses_default_approval_review_preferred_model() {
let provider = create_model_provider(
+35
View File
@@ -1,8 +1,11 @@
use serde::Deserialize;
use serde::Serialize;
use std::borrow::Cow;
use std::cmp::Ordering;
use std::fmt;
const NAMESPACED_TOOL_NAME_DELIMITER: &str = "__";
/// Identifies a callable tool, preserving the namespace split when the model
/// provides one.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
@@ -32,6 +35,20 @@ impl ToolName {
namespace: Some(namespace.into()),
}
}
/// Canonical flat name used when a boundary cannot preserve the namespace split.
pub fn canonical_flat_name(&self) -> Cow<'_, str> {
match self.namespace.as_deref() {
Some(namespace) => Cow::Owned(join_namespaced_tool_name(namespace, &self.name)),
None => Cow::Borrowed(self.name.as_str()),
}
}
}
fn join_namespaced_tool_name(namespace: &str, name: &str) -> String {
let namespace = namespace.trim_end_matches('_');
let name = name.trim_start_matches('_');
format!("{namespace}{NAMESPACED_TOOL_NAME_DELIMITER}{name}")
}
impl fmt::Display for ToolName {
@@ -74,3 +91,21 @@ impl From<&str> for ToolName {
Self::plain(name)
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn canonical_flat_name_inserts_one_namespace_delimiter() {
assert_eq!(
ToolName::namespaced("a__b", "__c")
.canonical_flat_name()
.as_ref(),
"a__b__c"
);
assert_eq!(ToolName::plain("plain").canonical_flat_name(), "plain");
}
}
+4
View File
@@ -53,14 +53,17 @@ pub use response_history::truncate_assistant_output_text_to_token_budget;
pub use responses_api::FreeformTool;
pub use responses_api::FreeformToolFormat;
pub use responses_api::LoadableToolSpec;
pub use responses_api::NamespaceToolSpecMode;
pub use responses_api::ResponsesApiNamespace;
pub use responses_api::ResponsesApiNamespaceTool;
pub use responses_api::ResponsesApiTool;
pub use responses_api::coalesce_loadable_tool_specs;
pub use responses_api::default_namespace_description;
pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::flatten_responses_api_namespace;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::serialize_loadable_tool_specs;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_call::ConversationHistory;
pub use tool_call::ExtensionTurnItem;
@@ -105,3 +108,4 @@ pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;
pub use tool_spec::ToolSpec;
pub use tool_spec::create_tools_json_for_responses_api;
pub use tool_spec::serialize_tool_specs;
+43
View File
@@ -48,6 +48,12 @@ pub enum LoadableToolSpec {
Namespace(ResponsesApiNamespace),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamespaceToolSpecMode {
Preserve,
Flatten,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiNamespace {
pub name: String,
@@ -104,6 +110,43 @@ pub fn coalesce_loadable_tool_specs(
coalesced_specs
}
pub fn serialize_loadable_tool_specs(
specs: impl IntoIterator<Item = LoadableToolSpec>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
) -> Vec<LoadableToolSpec> {
let specs = coalesce_loadable_tool_specs(specs);
match namespace_tool_spec_mode {
NamespaceToolSpecMode::Preserve => specs,
NamespaceToolSpecMode::Flatten => specs
.into_iter()
.flat_map(|spec| match spec {
LoadableToolSpec::Function(tool) => vec![LoadableToolSpec::Function(tool)],
LoadableToolSpec::Namespace(namespace) => {
flatten_responses_api_namespace(namespace)
.into_iter()
.map(LoadableToolSpec::Function)
.collect()
}
})
.collect(),
}
}
pub fn flatten_responses_api_namespace(namespace: ResponsesApiNamespace) -> Vec<ResponsesApiTool> {
namespace
.tools
.into_iter()
.map(|tool| match tool {
ResponsesApiNamespaceTool::Function(mut tool) => {
tool.name = ToolName::namespaced(namespace.name.as_str(), tool.name)
.canonical_flat_name()
.into_owned();
tool
}
})
.collect()
}
pub fn mcp_tool_to_responses_api_tool(
tool_name: &ToolName,
tool: &rmcp::model::Tool,
+21
View File
@@ -1,8 +1,10 @@
use crate::FreeformTool;
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::NamespaceToolSpecMode;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiTool;
use crate::flatten_responses_api_namespace;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -74,6 +76,25 @@ impl From<LoadableToolSpec> for ToolSpec {
}
}
pub fn serialize_tool_specs(
specs: impl IntoIterator<Item = ToolSpec>,
namespace_tool_spec_mode: NamespaceToolSpecMode,
) -> Vec<ToolSpec> {
match namespace_tool_spec_mode {
NamespaceToolSpecMode::Preserve => specs.into_iter().collect(),
NamespaceToolSpecMode::Flatten => specs
.into_iter()
.flat_map(|spec| match spec {
ToolSpec::Namespace(namespace) => flatten_responses_api_namespace(namespace)
.into_iter()
.map(ToolSpec::Function)
.collect(),
spec => vec![spec],
})
.collect(),
}
}
/// Returns JSON values that are compatible with Function Calling in the
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
+33
View File
@@ -6,9 +6,11 @@ use crate::AdditionalProperties;
use crate::FreeformTool;
use crate::FreeformToolFormat;
use crate::JsonSchema;
use crate::NamespaceToolSpecMode;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::create_tools_json_for_responses_api;
use crate::serialize_tool_specs;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -195,6 +197,37 @@ fn namespace_tool_spec_serializes_expected_wire_shape() {
);
}
#[test]
fn namespace_tool_specs_flatten_to_canonical_function_names() {
let specs = serialize_tool_specs(
[ToolSpec::Namespace(ResponsesApiNamespace {
name: "mcp__demo__".to_string(),
description: "Demo tools".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "__lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::default(),
output_schema: None,
})],
})],
NamespaceToolSpecMode::Flatten,
);
assert_eq!(
specs,
vec![ToolSpec::Function(ResponsesApiTool {
name: "mcp__demo__lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::default(),
output_schema: None,
})]
);
}
#[test]
fn web_search_tool_spec_serializes_expected_wire_shape() {
assert_eq!(