From f21dc4638803f40046c9e294b0349782928f6b36 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Wed, 5 Aug 2026 03:56:12 +0000 Subject: [PATCH] Canonicalize default tools under the `functions` namespace (#37022) ## What changed - Group top-level function and custom tool definitions into a single `functions` namespace for Responses Lite providers that support namespaced tools. - Normalize missing, empty, and explicit `functions` namespaces to the same tool identity across registration, routing, lifecycle hooks, configuration, and tool search. - Keep default tool names unprefixed in code mode, display output, and dispatch traces while preserving explicit non-default namespaces. ## Testing - Add coverage for Responses Lite serialization, tool search results, namespace normalization and collision handling, routing, lifecycle events, and code-mode namespace policies. GitOrigin-RevId: d48414005b5d22d39b11a198e19c47814d3a19f2 --- codex-rs/core/src/client.rs | 7 +- codex-rs/core/src/memory_usage.rs | 15 +-- codex-rs/core/src/tools/code_mode/mod.rs | 6 +- .../core/src/tools/code_mode/wait_handler.rs | 3 +- .../core/src/tools/executed_tool_calls.rs | 2 +- codex-rs/core/src/tools/mod.rs | 4 + codex-rs/core/src/tools/parallel.rs | 2 +- codex-rs/core/src/tools/registry.rs | 32 +++--- codex-rs/core/src/tools/registry_tests.rs | 84 +++++++++++++-- codex-rs/core/src/tools/router.rs | 4 +- codex-rs/core/src/tools/router_tests.rs | 37 +++++++ codex-rs/core/src/tools/spec_plan.rs | 3 + codex-rs/core/src/tools/spec_plan_tests.rs | 39 +++++++ .../core/src/tools/tool_dispatch_trace.rs | 7 +- .../src/tools/tool_dispatch_trace_tests.rs | 28 +++-- codex-rs/core/tests/suite/code_mode.rs | 9 +- codex-rs/core/tests/suite/responses_lite.rs | 14 ++- codex-rs/core/tests/suite/search_tool.rs | 29 ++--- codex-rs/ext/goal/src/extension.rs | 2 +- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/tool_name.rs | 18 ++++ codex-rs/tools/src/code_mode.rs | 4 + codex-rs/tools/src/code_mode_tests.rs | 16 +++ codex-rs/tools/src/lib.rs | 1 + codex-rs/tools/src/responses_api.rs | 17 +-- codex-rs/tools/src/tool_search.rs | 14 ++- codex-rs/tools/src/tool_search_tests.rs | 47 +++++++- codex-rs/tools/src/tool_spec.rs | 53 ++++++++- codex-rs/tools/src/tool_spec_tests.rs | 102 ++++++++++++++++++ 29 files changed, 522 insertions(+), 78 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d6b25864df..0253d324c5 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -89,6 +89,7 @@ use codex_rollout_trace::CompactionTraceContext; use codex_rollout_trace::InferenceTraceAttempt; use codex_rollout_trace::InferenceTraceContext; use codex_tools::create_tools_json_for_responses_api; +use codex_tools::create_tools_json_for_responses_lite; use codex_tools::create_tools_raw_json_for_responses_api; use eventsource_stream::Event; use eventsource_stream::EventStreamError; @@ -859,7 +860,11 @@ impl ModelClient { } } let (instructions, tools) = if model_info.use_responses_lite { - let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let tools = if self.state.provider.capabilities().namespace_tools { + create_tools_json_for_responses_lite(&prompt.tools)? + } else { + create_tools_json_for_responses_api(&prompt.tools)? + }; let mut prefix = vec![ResponseItem::AdditionalTools { id: None, role: "developer".to_string(), diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index 17851b3aba..097c0cc117 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -31,16 +31,17 @@ pub(crate) fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option return None; }; - match ( - invocation.tool_name.namespace.as_deref(), - invocation.tool_name.name.as_str(), - ) { - (None, "shell_command") => serde_json::from_str::(arguments) + if !invocation.tool_name.is_default_namespace() { + return None; + } + + match invocation.tool_name.name.as_str() { + "shell_command" => serde_json::from_str::(arguments) .ok() .map(|params| params.command), - (None, "exec_command") => serde_json::from_str::(arguments) + "exec_command" => serde_json::from_str::(arguments) .ok() .map(|params| params.cmd), - (Some(_), _) | (None, _) => None, + _ => None, } } diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 57f1e6725c..6b6a86d81d 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -62,9 +62,9 @@ pub(crate) fn default_exec_yield_time_override_ms(features: &Features) -> Option .then_some(BUFFERED_EXEC_YIELD_TIME_MS) } -/// Returns true for the un-namespaced code-mode `exec` tool. +/// Returns true for the code-mode `exec` tool in the default namespace. pub(crate) fn is_exec_tool_name(tool_name: &ToolName) -> bool { - tool_name.namespace.is_none() && tool_name.name == PUBLIC_TOOL_NAME + tool_name.is_default_namespace() && tool_name.name == PUBLIC_TOOL_NAME } #[derive(Clone)] @@ -339,7 +339,7 @@ async fn call_nested_tool( }; let call = ToolCall { - tool_name, + tool_name: tool_name.with_default_namespace(), call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()), payload, encrypted_function_args: None, diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index d9e656775a..4f65cef15e 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -82,7 +82,8 @@ impl CodeModeWaitHandler { ); let result = match payload { ToolPayload::Function { arguments } - if tool_name.namespace.is_none() && tool_name.name.as_str() == WAIT_TOOL_NAME => + if tool_name.is_default_namespace() + && tool_name.name.as_str() == WAIT_TOOL_NAME => { let args: ExecWaitArgs = parse_arguments(&arguments).inspect_err(|_error| { telemetry.finish(/*success*/ false); diff --git a/codex-rs/core/src/tools/executed_tool_calls.rs b/codex-rs/core/src/tools/executed_tool_calls.rs index 5819cee083..0a52fcc3a7 100644 --- a/codex-rs/core/src/tools/executed_tool_calls.rs +++ b/codex-rs/core/src/tools/executed_tool_calls.rs @@ -70,7 +70,7 @@ impl ExecutedToolCallRecorder { ) { if matches!(source, ToolCallSource::Direct) && matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) - && call.tool_name.namespace.is_none() + && call.tool_name.is_default_namespace() && matches!( (call.tool_name.name.as_str(), &call.payload), ( diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index eba0fad723..89d0cd136f 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -40,6 +40,10 @@ pub(crate) const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = /// names still require a single flattened string. Keep comparisons and sorting /// on `ToolName` itself; use this only when crossing those boundaries. pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { + if tool_name.is_default_namespace() { + return Cow::Borrowed(tool_name.name.as_str()); + } + match tool_name.namespace.as_deref() { Some(namespace) => { let mut name = String::with_capacity(namespace.len() + tool_name.name.len()); diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index b19f7e324d..4f1d0b75a7 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -266,7 +266,7 @@ impl ToolCallRuntime { } fn abort_message(call: &ToolCall, secs: f32) -> String { - if call.tool_name.namespace.is_none() + if call.tool_name.is_default_namespace() && matches!( call.tool_name.name.as_str(), "shell_command" | "unified_exec" diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 63c6ecc012..fd41dcb11c 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -290,7 +290,8 @@ impl ToolRegistry { runtime: Arc, exposure: ToolExposure, ) { - match self.tools.entry(runtime.tool_name()) { + let tool_name = runtime.tool_name().with_default_namespace(); + match self.tools.entry(tool_name) { Entry::Vacant(entry) => { entry.insert(RegisteredTool { runtime, exposure }); } @@ -302,7 +303,7 @@ impl ToolRegistry { } pub(crate) fn prepend_trusted(&mut self, runtime: Arc) { - let tool_name = runtime.tool_name(); + let tool_name = runtime.tool_name().with_default_namespace(); if self.tools.contains_key(&tool_name) { error_or_panic(format!("tool {tool_name} already registered")); return; @@ -323,8 +324,8 @@ impl ToolRegistry { runtime: Arc, exposure: ToolExposure, ) -> bool { - let tool_name = runtime.tool_name(); - if tool_name.namespace.is_none() && tool_name.name == "shell_command" { + let tool_name = runtime.tool_name().with_default_namespace(); + if tool_name.is_default_namespace() && tool_name.name == "shell_command" { tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name"); if self.tools.contains_key(&tool_name) { self.record_collision(tool_name); @@ -358,7 +359,9 @@ impl ToolRegistry { } pub(crate) fn remove(&mut self, tool_name: &ToolName) -> Option> { - self.tools.shift_remove(tool_name).map(|tool| tool.runtime) + self.tools + .shift_remove(&tool_name.clone().with_default_namespace()) + .map(|tool| tool.runtime) } pub(crate) fn entries(&self) -> impl Iterator { @@ -372,7 +375,7 @@ impl ToolRegistry { pub(crate) fn deferred_tool_namespaces(&self) -> BTreeMap { let mut namespaces = BTreeMap::::new(); for (name, tool) in &self.tools { - if !tool.exposure.is_deferred() { + if !tool.exposure.is_deferred() || name.is_default_namespace() { continue; } let Some(namespace) = &name.namespace else { @@ -410,7 +413,9 @@ impl ToolRegistry { } pub(crate) fn tool(&self, name: &ToolName) -> Option> { - self.tools.get(name).map(|tool| Arc::clone(&tool.runtime)) + self.tools + .get(&name.clone().with_default_namespace()) + .map(|tool| Arc::clone(&tool.runtime)) } #[cfg(test)] @@ -422,7 +427,9 @@ impl ToolRegistry { #[cfg(test)] pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure) + self.tools + .get(&name.clone().with_default_namespace()) + .map(|tool| tool.exposure) } pub(crate) fn create_diff_consumer( @@ -433,7 +440,7 @@ impl ToolRegistry { } pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option { - let tool = self.tools.get(name)?; + let tool = self.tools.get(&name.clone().with_default_namespace())?; Some(tool.exposure != ToolExposure::Hidden && tool.runtime.supports_parallel_tool_calls()) } @@ -504,7 +511,6 @@ impl ToolRegistry { return Err(err); } }; - let telemetry_tags = tool.telemetry_tags(&invocation); let mut tool_result_tags = Vec::with_capacity(base_tool_result_tags.len() + telemetry_tags.len() + 1); @@ -773,10 +779,8 @@ async fn handle_any_tool( fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName { if invocation.tool_name.name == "spawn_agent" - && matches!( - invocation.tool_name.namespace.as_deref(), - None | Some(MULTI_AGENT_V1_NAMESPACE) - ) + && (invocation.tool_name.is_default_namespace() + || invocation.tool_name.namespace.as_deref() == Some(MULTI_AGENT_V1_NAMESPACE)) { return HookToolName::spawn_agent(); } diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index d479e81725..9736da4442 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::session::session::Session; use crate::session::step_context::StepContext; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use futures::future::BoxFuture; use pretty_assertions::assert_eq; use std::sync::atomic::AtomicUsize; @@ -80,7 +81,11 @@ impl ToolExecutor for LifecycleTestHandler { test_spec(&self.tool_name) } - fn handle(&self, _invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + assert_eq!( + invocation.tool_name, + self.tool_name.clone().with_default_namespace() + ); Box::pin(self.handle_call()) } } @@ -172,7 +177,7 @@ impl codex_extension_api::ToolLifecycleContributor for ToolLifecycleRecorder { } #[test] -fn handler_looks_up_namespaced_aliases_explicitly() { +fn handler_normalizes_only_the_default_namespace() { let namespace = "mcp__codex_apps__gmail"; let tool_name = "gmail_get_recent_emails"; let plain_name = codex_tools::ToolName::plain(tool_name); @@ -187,6 +192,11 @@ fn handler_looks_up_namespaced_aliases_explicitly() { ToolRegistry::from_tools([Arc::clone(&plain_handler), Arc::clone(&namespaced_handler)]); let plain = registry.tool(&plain_name); + let default_namespaced = registry.tool(&codex_tools::ToolName::namespaced( + DEFAULT_FUNCTION_NAMESPACE, + tool_name, + )); + let empty_namespaced = registry.tool(&codex_tools::ToolName::namespaced("", tool_name)); let namespaced = registry.tool(&namespaced_name); let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced( "mcp__codex_apps__calendar", @@ -201,6 +211,16 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .as_ref() .is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler)) ); + assert!( + default_namespaced + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler)) + ); + assert!( + empty_namespaced + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler)) + ); assert!( namespaced .as_ref() @@ -208,6 +228,45 @@ fn handler_looks_up_namespaced_aliases_explicitly() { ); } +#[test] +fn registry_rejects_default_namespace_alias_collisions() { + let plain_name = codex_tools::ToolName::plain("lookup"); + let namespaced_name = codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup"); + + for [first_name, duplicate_name] in [ + [plain_name.clone(), namespaced_name.clone()], + [namespaced_name, plain_name], + ] { + let winner = Arc::new(TestHandler { + tool_name: first_name.clone(), + }) as Arc; + let mut registry = ToolRegistry::from_tools([Arc::clone(&winner)]); + + assert!(!registry.register_external(Arc::new(TestHandler { + tool_name: duplicate_name.clone(), + }))); + assert!( + registry + .tool(&duplicate_name) + .is_some_and(|handler| Arc::ptr_eq(&handler, &winner)) + ); + assert_eq!( + registry.tool_exposure(&duplicate_name), + Some(ToolExposure::Direct) + ); + assert_eq!( + registry.supports_parallel_tool_calls(&duplicate_name), + Some(false) + ); + assert!( + registry + .remove(&duplicate_name) + .is_some_and(|handler| Arc::ptr_eq(&handler, &winner)) + ); + assert!(registry.tool(&first_name).is_none()); + } +} + #[test] fn registry_preserves_external_winners_and_trusted_synthetic_order() { let handler = |tool_name| Arc::new(TestHandler { tool_name }) as Arc; @@ -248,6 +307,12 @@ fn reserved_shell_command_rejects_external_runtimes_without_a_builtin() { handler(shell_command_name.clone()), ToolExposure::Direct, )); + assert!( + !registry.register_external(handler(codex_tools::ToolName::namespaced( + DEFAULT_FUNCTION_NAMESPACE, + "shell_command", + ))) + ); assert!(registry.tool(&shell_command_name).is_none()); assert_eq!(registry.first_collision(), None); @@ -449,6 +514,7 @@ async fn spawn_agent_function_tools_use_agent_matcher_alias() { let hook_payloads = [ codex_tools::ToolName::plain("spawn_agent"), + codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "spawn_agent"), codex_tools::ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent"), ] .into_iter() @@ -477,6 +543,10 @@ async fn spawn_agent_function_tools_use_agent_matcher_alias() { tool_name: HookToolName::spawn_agent(), tool_input: serde_json::json!({ "message": "inspect this repo" }), }), + Some(PreToolUsePayload { + tool_name: HookToolName::spawn_agent(), + tool_input: serde_json::json!({ "message": "inspect this repo" }), + }), ] ); } @@ -565,7 +635,7 @@ fn post_tool_use_feedback_output_keeps_code_mode_result_typed() { } #[tokio::test] -async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { +async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyhow::Result<()> { let (mut session, turn) = crate::session::tests::make_session_and_context().await; let records = Arc::new(std::sync::Mutex::new(Vec::new())); let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); @@ -575,7 +645,7 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { session.services.extensions = Arc::new(builder.build()); let ok_tool = codex_tools::ToolName::plain("ok_tool"); - let failing_tool = codex_tools::ToolName::plain("failing_tool"); + let failing_tool = codex_tools::ToolName::namespaced("extensions", "failing_tool"); let ok_handler = Arc::new(LifecycleTestHandler { tool_name: ok_tool.clone(), result: LifecycleTestResult::Ok { success: false }, @@ -594,7 +664,7 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { Arc::clone(&session), Arc::clone(&turn), "ok-call", - ok_tool.clone(), + codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "ok_tool"), ), /*terminal_outcome_reached*/ None, ) @@ -619,11 +689,11 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { let expected = vec![ RecordedToolLifecycle::Start { call_id: "ok-call".to_string(), - tool_name: ok_tool.clone(), + tool_name: ok_tool.clone().with_default_namespace(), }, RecordedToolLifecycle::Finish { call_id: "ok-call".to_string(), - tool_name: ok_tool, + tool_name: ok_tool.with_default_namespace(), outcome: codex_extension_api::ToolCallOutcome::Completed { success: false }, }, RecordedToolLifecycle::Start { diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index e54a96377a..83edd0463a 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -161,7 +161,7 @@ impl ToolRouter { call_id, .. } => { - let tool_name = ToolName::new(namespace, name); + let tool_name = ToolName::new(namespace, name).with_default_namespace(); Ok(Some(ToolCall { tool_name, call_id, @@ -196,7 +196,7 @@ impl ToolRouter { call_id, .. } => Ok(Some(ToolCall { - tool_name: ToolName::new(namespace, name), + tool_name: ToolName::new(namespace, name).with_default_namespace(), call_id, payload: ToolPayload::Custom { input }, encrypted_function_args: None, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 251d2825ed..690fb9ca2b 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -19,6 +19,7 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; @@ -268,6 +269,42 @@ async fn build_custom_tool_call_uses_namespace_for_registry_name() -> anyhow::Re Ok(()) } +#[test] +fn build_tool_call_normalizes_default_function_and_custom_namespaces() -> anyhow::Result<()> { + for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] { + let function_call = ToolRouter::build_tool_call(ResponseItem::FunctionCall { + id: None, + name: "lookup".to_string(), + namespace: namespace.map(str::to_string), + arguments: "{}".to_string(), + encrypted_function_args: None, + call_id: "call-function".to_string(), + internal_chat_message_metadata_passthrough: None, + })? + .expect("function_call should produce a tool call"); + let custom_call = ToolRouter::build_tool_call(ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-custom".to_string(), + name: "apply_patch".to_string(), + namespace: namespace.map(str::to_string), + input: "patch".to_string(), + internal_chat_message_metadata_passthrough: None, + })? + .expect("custom_tool_call should produce a tool call"); + + assert_eq!( + [function_call.tool_name, custom_call.tool_name], + [ + ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup"), + ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "apply_patch"), + ] + ); + } + + Ok(()) +} + #[tokio::test] async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 729f5256e3..50464f5c1e 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -205,6 +205,7 @@ fn apply_mcp_tool_exposure_policy( let Some(omitted_exposures) = omitted_exposures_by_tool.get(&tool_name) else { continue; }; + let tool_name = tool_name.with_default_namespace(); let mut exposures = ToolExposures::ALL.difference(*omitted_exposures); if tool_name.namespace.as_ref().is_some_and(|namespace| { @@ -368,6 +369,7 @@ fn apply_direct_model_only_namespace_overrides( let configured = tool .runtime .tool_name() + .with_default_namespace() .namespace .as_ref() .is_some_and(|namespace| { @@ -591,6 +593,7 @@ fn is_hidden_by_code_mode_only( } fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { + let tool_name = tool_name.clone().with_default_namespace(); tool_name.namespace.as_ref().is_some_and(|namespace| { turn_context .config diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 4ed42d46da..eff89aa3f4 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1824,6 +1824,25 @@ async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { assert!(!exec.description.contains("direct_only_lookup(args:")); } +#[tokio::test] +async fn code_mode_only_exposes_default_namespace_tools_directly() { + let plan = probe(|turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + update_config(turn, |config| { + config.code_mode.direct_only_tool_namespaces = vec!["functions".to_string()]; + }); + }) + .await; + + plan.assert_visible_contains(&["update_plan"]); + assert_eq!(plan.exposure("update_plan"), ToolExposure::DirectModelOnly); + + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("update_plan(args:")); +} + #[tokio::test] async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { let plan = probe_with( @@ -1860,6 +1879,26 @@ async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { ]); } +#[tokio::test] +async fn code_mode_excludes_default_namespace_tools() { + let plan = probe(|turn| { + set_feature(turn, Feature::CodeMode, /*enabled*/ true); + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["functions".to_string()]; + }); + }) + .await; + + plan.assert_visible_contains(&["update_plan"]); + plan.assert_registered_contains(&["update_plan"]); + assert_eq!(plan.exposure("update_plan"), ToolExposure::Direct); + + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("update_plan(args:")); +} + #[tokio::test] async fn multi_agent_feature_selects_one_agent_tool_family() { let v1 = probe(|turn| { diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index cb7c34aa93..deb5fa02bc 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -80,7 +80,12 @@ fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option anyhow::Resu let session = Arc::new(session); let turn = Arc::new(turn); + let mut invocation = test_invocation( + session, + turn, + "wait-call", + WAIT_TOOL_NAME, + ToolCallSource::Direct, + r#"{"cell_id":"noop","terminate":true}"#, + ); + invocation.tool_name = invocation.tool_name.with_default_namespace(); + assert!( + super::tool_dispatch_invocation(&invocation) + .expect("wait calls should produce a trace invocation") + .tool_namespace + .is_none() + ); + registry - .dispatch_any_with_terminal_outcome( - test_invocation( - session, - turn, - "wait-call", - WAIT_TOOL_NAME, - ToolCallSource::Direct, - r#"{"cell_id":"noop","terminate":true}"#, - ), - /*terminal_outcome_reached*/ None, - ) + .dispatch_any_with_terminal_outcome(invocation, /*terminal_outcome_reached*/ None) .await?; let replayed = codex_rollout_trace::replay_bundle(single_bundle_dir(temp.path())?)?; diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 5f8d545167..dc49f84919 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -4623,7 +4623,14 @@ text(JSON.stringify({ }) }) .and_then(|item| item["tools"].as_array()) - .expect("the Responses Lite request should contain its additional tools") + .and_then(|tools| { + tools.iter().find(|tool| { + tool.get("type").and_then(Value::as_str) == Some("namespace") + && tool.get("name").and_then(Value::as_str) == Some("functions") + }) + }) + .and_then(|namespace| namespace["tools"].as_array()) + .expect("the Responses Lite request should contain its default-namespace tools") } else { first_body["tools"] .as_array() diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b6a565a926..75700edb8d 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -129,7 +129,19 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< ); let tools = additional_tools(&body)?; - assert!(!tools.is_empty()); + let functions_namespaces = tools + .iter() + .filter(|tool| tool["type"] == "namespace" && tool["name"] == "functions") + .collect::>(); + assert_eq!(functions_namespaces.len(), 1); + assert_eq!(functions_namespaces[0]["description"], ""); + assert!(has_namespaced_tool(tools, "functions", "wait")); + assert!(has_namespaced_tool(tools, "functions", "exec")); + assert!( + tools + .iter() + .all(|tool| { !matches!(tool["type"].as_str(), Some("function" | "custom")) }) + ); let client_metadata = body["client_metadata"] .as_object() .context("Responses request should include client metadata")?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 5a0d597e8d..dc95027ac8 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -55,7 +55,7 @@ use core_test_support::apps_test_server::search_capable_apps_builder as configur use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_custom_tool_call; +use core_test_support::responses::ev_custom_tool_call_with_namespace; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -1001,7 +1001,7 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() -> ]), sse(vec![ ev_response_created("resp-2"), - ev_custom_tool_call("custom-1", "custom_echo", "hello"), + ev_custom_tool_call_with_namespace("custom-1", "functions", "custom_echo", "hello"), ev_completed("resp-2"), ]), sse(vec![ @@ -1035,15 +1035,20 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() -> assert_eq!( tool_search_output_tools(&requests[1], "search-1"), vec![json!({ - "type": "custom", - "name": "custom_echo", - "description": "Echo a custom payload.", - "defer_loading": true, - "format": { - "type": "grammar", - "syntax": "lark", - "definition": "start: /.+/", - }, + "type": "namespace", + "name": "functions", + "description": "", + "tools": [{ + "type": "custom", + "name": "custom_echo", + "description": "Echo a custom payload.", + "defer_loading": true, + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/", + }, + }], })] ); let output = requests[2].custom_tool_call_output("custom-1"); @@ -1052,7 +1057,7 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() -> .as_str() .expect("custom tool output should contain serialized JSON"), )?; - assert_eq!(output, json!({ "echo": "hello", "namespace": null })); + assert_eq!(output, json!({ "echo": "hello", "namespace": "functions" })); Ok(()) } diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 4fa1081db9..dcf6bad73d 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -372,7 +372,7 @@ where }; let should_count_for_goal_progress = runtime.is_enabled() && tool_attempt_counts_for_goal_progress(input.outcome) - && !(input.tool_name.namespace.is_none() + && !(input.tool_name.is_default_namespace() && input.tool_name.name == UPDATE_GOAL_TOOL_NAME); if !should_count_for_goal_progress { return; diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index b2b04b17a8..5a969d4661 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -9,6 +9,7 @@ pub use agent_path::AgentPath; pub use response_item_id::ResponseItemId; pub use session_id::SessionId; pub use thread_id::ThreadId; +pub use tool_name::DEFAULT_FUNCTION_NAMESPACE; pub use tool_name::ToolName; pub mod approvals; pub mod capabilities; diff --git a/codex-rs/protocol/src/tool_name.rs b/codex-rs/protocol/src/tool_name.rs index 3d7219abe8..1eee82e211 100644 --- a/codex-rs/protocol/src/tool_name.rs +++ b/codex-rs/protocol/src/tool_name.rs @@ -3,6 +3,9 @@ use serde::Serialize; use std::cmp::Ordering; use std::fmt; +/// Namespace used for top-level function and custom tools. +pub const DEFAULT_FUNCTION_NAMESPACE: &str = "functions"; + /// Identifies a callable tool, preserving the namespace split when the model /// provides one. #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] @@ -32,11 +35,26 @@ impl ToolName { namespace: Some(namespace.into()), } } + + pub fn with_default_namespace(mut self) -> Self { + if self.namespace.as_deref().is_none_or(str::is_empty) { + self.namespace = Some(DEFAULT_FUNCTION_NAMESPACE.to_string()); + } + self + } + + pub fn is_default_namespace(&self) -> bool { + matches!( + self.namespace.as_deref(), + None | Some("") | Some(DEFAULT_FUNCTION_NAMESPACE) + ) + } } impl fmt::Display for ToolName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.namespace { + Some(_) if self.is_default_namespace() => f.write_str(&self.name), Some(namespace) => write!(f, "{namespace}{}", self.name), None => f.write_str(&self.name), } diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index 8dc7cfc6b0..e5580ede13 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -178,6 +178,10 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec String { + if tool_name.is_default_namespace() { + return tool_name.name.clone(); + } + match tool_name.namespace.as_deref() { Some(namespace) if namespace.ends_with('_') || tool_name.name.starts_with('_') => { format!("{namespace}{}", tool_name.name) diff --git a/codex-rs/tools/src/code_mode_tests.rs b/codex-rs/tools/src/code_mode_tests.rs index bf5db21bfc..0e6eb7f694 100644 --- a/codex-rs/tools/src/code_mode_tests.rs +++ b/codex-rs/tools/src/code_mode_tests.rs @@ -1,4 +1,5 @@ use super::augment_tool_spec_for_code_mode; +use super::code_mode_name_for_tool_name; use super::tool_spec_to_code_mode_tool_definition; use crate::AdditionalProperties; use crate::FreeformTool; @@ -13,6 +14,21 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; +#[test] +fn code_mode_tool_names_do_not_prefix_the_default_namespace() { + for tool_name in [ + ToolName::plain("apply_patch"), + ToolName::namespaced("functions", "apply_patch"), + ] { + assert_eq!(code_mode_name_for_tool_name(&tool_name), "apply_patch"); + } + + assert_eq!( + code_mode_name_for_tool_name(&ToolName::namespaced("editor", "apply_patch")), + "editor__apply_patch" + ); +} + #[test] fn augment_tool_spec_for_code_mode_augments_function_tools() { assert_eq!( diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index be8f9de953..de5749afa7 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -106,4 +106,5 @@ 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::create_tools_json_for_responses_lite; pub use tool_spec::create_tools_raw_json_for_responses_api; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index 2384e726de..e63372ac94 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -3,6 +3,7 @@ use crate::ToolDefinition; use crate::ToolName; use crate::parse_dynamic_tool; use crate::parse_mcp_tool; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use serde::Deserialize; use serde::Serialize; @@ -46,8 +47,6 @@ pub enum LoadableToolSpec { #[allow(dead_code)] #[serde(rename = "function")] Function(ResponsesApiTool), - #[serde(rename = "custom")] - Custom(FreeformTool), #[serde(rename = "namespace")] Namespace(ResponsesApiNamespace), } @@ -60,7 +59,11 @@ pub struct ResponsesApiNamespace { } pub fn default_namespace_description(namespace_name: &str) -> String { - format!("Tools in the {namespace_name} namespace.") + if namespace_name == DEFAULT_FUNCTION_NAMESPACE { + String::new() + } else { + format!("Tools in the {namespace_name} namespace.") + } } #[derive(Debug, Clone, Serialize, PartialEq)] @@ -87,8 +90,8 @@ pub fn coalesce_loadable_tool_specs( let mut coalesced_specs = Vec::new(); for spec in specs { match spec { - tool @ (LoadableToolSpec::Function(_) | LoadableToolSpec::Custom(_)) => { - coalesced_specs.push(tool); + LoadableToolSpec::Function(tool) => { + coalesced_specs.push(LoadableToolSpec::Function(tool)); } LoadableToolSpec::Namespace(mut namespace) => { if let Some(existing_namespace) = @@ -98,9 +101,7 @@ pub fn coalesce_loadable_tool_specs( { Some(existing_namespace) } - LoadableToolSpec::Function(_) - | LoadableToolSpec::Custom(_) - | LoadableToolSpec::Namespace(_) => None, + LoadableToolSpec::Function(_) | LoadableToolSpec::Namespace(_) => None, }) { existing_namespace.tools.append(&mut namespace.tools); diff --git a/codex-rs/tools/src/tool_search.rs b/codex-rs/tools/src/tool_search.rs index f6a0074df7..3ca1ae22c0 100644 --- a/codex-rs/tools/src/tool_search.rs +++ b/codex-rs/tools/src/tool_search.rs @@ -1,10 +1,12 @@ use crate::JsonSchema; use crate::LoadableToolSpec; +use crate::ResponsesApiNamespace; use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; use crate::ToolSearchSourceInfo; use crate::ToolSpec; use crate::default_namespace_description; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; #[derive(Clone, PartialEq)] pub struct ToolSearchEntry { @@ -36,11 +38,19 @@ impl ToolSearchInfo { ToolSpec::Function(mut tool) => { tool.defer_loading = Some(true); tool.output_schema = None; - LoadableToolSpec::Function(tool) + LoadableToolSpec::Namespace(ResponsesApiNamespace { + name: DEFAULT_FUNCTION_NAMESPACE.to_string(), + description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + }) } ToolSpec::Freeform(mut tool) => { tool.defer_loading = Some(true); - LoadableToolSpec::Custom(tool) + LoadableToolSpec::Namespace(ResponsesApiNamespace { + name: DEFAULT_FUNCTION_NAMESPACE.to_string(), + description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Custom(tool)], + }) } ToolSpec::Namespace(mut namespace) => { if namespace.description.trim().is_empty() { diff --git a/codex-rs/tools/src/tool_search_tests.rs b/codex-rs/tools/src/tool_search_tests.rs index 6ea6eed2ca..aed34a6e3b 100644 --- a/codex-rs/tools/src/tool_search_tests.rs +++ b/codex-rs/tools/src/tool_search_tests.rs @@ -2,6 +2,43 @@ use super::*; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +#[test] +fn top_level_function_search_results_use_the_default_namespace() { + let function_tool = ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ), + output_schema: Some(serde_json::json!({ "type": "object" })), + }; + let search_info = ToolSearchInfo::from_tool_spec( + ToolSpec::Function(function_tool.clone()), + /*source_info*/ None, + ) + .expect("top-level function should be searchable"); + + assert_eq!( + (search_info.entry.search_text, search_info.entry.output), + ( + "lookup_order lookup order Look up an order".to_string(), + LoadableToolSpec::Namespace(ResponsesApiNamespace { + name: "functions".to_string(), + description: String::new(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + defer_loading: Some(true), + output_schema: None, + ..function_tool + })], + }), + ) + ); +} + #[test] fn top_level_custom_tools_are_searchable() { let custom_tool = crate::FreeformTool { @@ -24,9 +61,13 @@ fn top_level_custom_tools_are_searchable() { (search_info.entry.search_text, search_info.entry.output), ( "apply_patch Apply a patch lark".to_string(), - LoadableToolSpec::Custom(crate::FreeformTool { - defer_loading: Some(true), - ..custom_tool + LoadableToolSpec::Namespace(ResponsesApiNamespace { + name: "functions".to_string(), + description: String::new(), + tools: vec![ResponsesApiNamespaceTool::Custom(crate::FreeformTool { + defer_loading: Some(true), + ..custom_tool + })], }), ) ); diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index fe8523c076..530da1be16 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -2,7 +2,10 @@ use crate::FreeformTool; use crate::JsonSchema; use crate::LoadableToolSpec; use crate::ResponsesApiNamespace; +use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; +use crate::default_namespace_description; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; @@ -68,7 +71,6 @@ impl From for ToolSpec { fn from(value: LoadableToolSpec) -> Self { match value { LoadableToolSpec::Function(tool) => ToolSpec::Function(tool), - LoadableToolSpec::Custom(tool) => ToolSpec::Freeform(tool), LoadableToolSpec::Namespace(namespace) => ToolSpec::Namespace(namespace), } } @@ -90,6 +92,55 @@ pub fn create_tools_json_for_responses_api( Ok(tools_json) } +pub fn create_tools_json_for_responses_lite( + tools: &[ToolSpec], +) -> Result, serde_json::Error> { + let mut functions = ResponsesApiNamespace { + name: DEFAULT_FUNCTION_NAMESPACE.to_string(), + description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE), + tools: Vec::new(), + }; + let mut functions_index = None; + let mut tools_json = Vec::new(); + + for tool in tools { + match tool { + ToolSpec::Function(tool) => { + functions + .tools + .push(ResponsesApiNamespaceTool::Function(tool.clone())); + } + ToolSpec::Freeform(tool) => { + functions + .tools + .push(ResponsesApiNamespaceTool::Custom(tool.clone())); + } + ToolSpec::Namespace(namespace) if namespace.name == DEFAULT_FUNCTION_NAMESPACE => { + if !namespace.description.trim().is_empty() { + functions.description = namespace.description.clone(); + } + functions.tools.extend(namespace.tools.clone()); + } + tool => { + tools_json.push(serde_json::to_value(tool)?); + continue; + } + } + functions_index.get_or_insert(tools_json.len()); + } + + if let Some(functions_index) = functions_index + && !functions.tools.is_empty() + { + tools_json.insert( + functions_index, + serde_json::to_value(ToolSpec::Namespace(functions))?, + ); + } + + Ok(tools_json) +} + /// Returns raw JSON that can be embedded directly in a Responses API request. pub fn create_tools_raw_json_for_responses_api( tools: &[ToolSpec], diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 9b59f780e5..d8fcee9cf8 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -9,6 +9,7 @@ use crate::JsonSchema; use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; use crate::create_tools_json_for_responses_api; +use crate::create_tools_json_for_responses_lite; use crate::create_tools_raw_json_for_responses_api; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; @@ -145,6 +146,107 @@ fn create_tools_json_for_responses_api_includes_top_level_name() { ); } +fn responses_lite_function(name: &str) -> ResponsesApiTool { + ResponsesApiTool { + name: name.to_string(), + description: format!("{name} tool"), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ), + output_schema: None, + } +} + +#[test] +fn responses_lite_groups_default_function_and_custom_tools() { + let tools = create_tools_json_for_responses_lite(&[ + ToolSpec::ToolSearch { + execution: "client".to_string(), + description: "Search tools".to_string(), + parameters: JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ), + }, + ToolSpec::Function(responses_lite_function("lookup_order")), + ToolSpec::Namespace(ResponsesApiNamespace { + name: "editor".to_string(), + description: "Editing tools".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function( + responses_lite_function("edit"), + )], + }), + ToolSpec::Freeform(FreeformTool { + name: "exec".to_string(), + description: "Run code".to_string(), + defer_loading: None, + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: /.+/".to_string(), + }, + }), + ToolSpec::Namespace(ResponsesApiNamespace { + name: "functions".to_string(), + description: "Existing default tools".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function( + responses_lite_function("existing"), + )], + }), + ToolSpec::Function(responses_lite_function("last")), + ]) + .expect("serialize Responses Lite tools"); + + assert_eq!(tools.len(), 3); + assert_eq!(tools[0]["type"], "tool_search"); + assert_eq!(tools[2]["type"], "namespace"); + assert_eq!(tools[2]["name"], "editor"); + assert_eq!(tools[2]["tools"][0]["name"], "edit"); + assert_eq!(tools[1]["type"], "namespace"); + assert_eq!(tools[1]["name"], "functions"); + assert_eq!(tools[1]["description"], "Existing default tools"); + assert_eq!( + tools[1]["tools"] + .as_array() + .expect("functions namespace should contain tools") + .iter() + .map(|tool| (tool["type"].as_str(), tool["name"].as_str())) + .collect::>(), + [ + (Some("function"), Some("lookup_order")), + (Some("custom"), Some("exec")), + (Some("function"), Some("existing")), + (Some("function"), Some("last")), + ] + ); +} + +#[test] +fn responses_lite_preserves_empty_functions_namespace_description() { + let tools = create_tools_json_for_responses_lite(&[ToolSpec::Function( + responses_lite_function("lookup_order"), + )]) + .expect("serialize Responses Lite tools"); + assert_eq!(tools[0]["description"], ""); +} + +#[test] +fn responses_lite_does_not_add_an_empty_functions_namespace() { + let tools = + create_tools_json_for_responses_lite(&[ToolSpec::Namespace(ResponsesApiNamespace { + name: "functions".to_string(), + description: "Empty default tools".to_string(), + tools: Vec::new(), + })]) + .expect("serialize Responses Lite tools"); + assert!(tools.is_empty()); +} + #[test] fn raw_tool_json_matches_value_encoding() { let specs = vec![ToolSpec::Function(ResponsesApiTool {