diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0d2239386f..8d71c6d02e 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -84,6 +84,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_utils_output_truncation::approx_tokens_from_byte_count; use eventsource_stream::Event; use eventsource_stream::EventStreamError; @@ -785,7 +786,11 @@ impl ModelClient { if !self.state.provider.info().is_openai() { input.iter_mut().for_each(ResponseItem::clear_metadata); } - let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let tools = if model_info.use_responses_lite { + create_tools_json_for_responses_lite(&prompt.tools)? + } else { + create_tools_json_for_responses_api(&prompt.tools)? + }; let (instructions, tools) = if model_info.use_responses_lite { let additional_tools = ResponseItem::AdditionalTools { id: None, diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index 4655b63931..ea8812bb97 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -31,16 +31,17 @@ 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/session/turn.rs b/codex-rs/core/src/session/turn.rs index d7e64dc5d2..47d365d4ec 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2113,7 +2113,7 @@ async fn try_run_sampling_request( } ResponseEvent::OutputItemAdded(item) => { if let ResponseItem::CustomToolCall { call_id, name, .. } = &item { - let tool_name = ToolName::plain(name.as_str()); + let tool_name = ToolName::function(name.as_str()); active_tool_argument_diff_consumer = tool_runtime .create_diff_consumer(&tool_name) .map(|consumer| (call_id.clone(), consumer)); diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 09918033e6..1550b5d3e1 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -47,9 +47,9 @@ pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME; pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME; pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_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)] 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 953391a573..57d6d32994 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -73,7 +73,8 @@ impl CodeModeWaitHandler { 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)?; let exec = ExecContext { session, turn }; diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 1bc9786217..29ddb8e603 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -37,6 +37,9 @@ 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 c40886b56c..36cbc9186a 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -222,7 +222,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 2220d4244a..efe12def1d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -325,6 +325,10 @@ pub struct ToolRegistry { impl ToolRegistry { fn new(tools: HashMap>) -> Self { + let tools = tools + .into_iter() + .map(|(name, tool)| (name.with_default_namespace(), tool)) + .collect(); Self { tools } } @@ -332,7 +336,7 @@ impl ToolRegistry { pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { let mut tools_by_name = HashMap::new(); for tool in tools { - let name = tool.tool_name(); + let name = tool.tool_name().with_default_namespace(); if tools_by_name.contains_key(&name) { error_or_panic(format!("tool {name} already registered")); continue; @@ -357,7 +361,9 @@ impl ToolRegistry { } fn tool(&self, name: &ToolName) -> Option> { - self.tools.get(name).map(Arc::clone) + self.tools + .get(&name.clone().with_default_namespace()) + .map(Arc::clone) } #[cfg(test)] @@ -369,7 +375,7 @@ impl ToolRegistry { #[cfg(test)] pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure()) + self.tool(name).map(|tool| tool.exposure()) } pub(crate) fn create_diff_consumer( @@ -712,10 +718,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 67613f1d08..7fe7787311 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -140,7 +140,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); @@ -157,6 +157,7 @@ fn handler_looks_up_namespaced_aliases_explicitly() { ])); let plain = registry.tool(&plain_name); + let default_namespaced = registry.tool(&codex_tools::ToolName::function(tool_name)); let namespaced = registry.tool(&namespaced_name); let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced( "mcp__codex_apps__calendar", @@ -164,6 +165,7 @@ fn handler_looks_up_namespaced_aliases_explicitly() { )); assert_eq!(plain.is_some(), true); + assert_eq!(default_namespaced.is_some(), true); assert_eq!(namespaced.is_some(), true); assert_eq!(missing_namespaced.is_none(), true); assert!( @@ -171,6 +173,11 @@ 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!( namespaced .as_ref() diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index e0a5c0fe17..0ac654628f 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -120,7 +120,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, @@ -152,7 +152,7 @@ impl ToolRouter { call_id, .. } => Ok(Some(ToolCall { - tool_name: ToolName::plain(name), + tool_name: ToolName::function(name), call_id, payload: ToolPayload::Custom { input }, })), diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 19e2d955b2..f33842b38f 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -18,6 +18,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_tools::DEFAULT_FUNCTION_NAMESPACE; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; @@ -176,6 +177,39 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() 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(), + call_id: "call-function".to_string(), + })? + .expect("function_call should produce a tool call"); + assert_eq!( + function_call.tool_name, + ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup") + ); + } + + let custom_call = ToolRouter::build_tool_call(ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-custom".to_string(), + name: "apply_patch".to_string(), + input: "*** Begin Patch\n*** End Patch".to_string(), + })? + .expect("custom_tool_call should produce a tool call"); + assert_eq!( + custom_call.tool_name, + ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "apply_patch") + ); + + Ok(()) +} + #[test] fn build_tool_call_uses_canonical_tool_search_name() -> anyhow::Result<()> { let call = ToolRouter::build_tool_call(ResponseItem::ToolSearchCall { @@ -443,9 +477,7 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec tool.name.clone(), - }) + .map(|tool| tool.name().to_string()) .collect(), ), ToolSpec::Function(_) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index b6470888b4..6f4712e610 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -241,7 +241,7 @@ fn build_model_visible_specs_and_registry( let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); for runtime in &runtimes { - let tool_name = runtime.tool_name(); + let tool_name = runtime.tool_name().with_default_namespace(); if !seen_tool_names.insert(tool_name.clone()) { continue; } @@ -464,6 +464,9 @@ fn is_hidden_by_code_mode_only( } fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { + if tool_name.is_default_namespace() { + return false; + } tool_name.namespace.as_ref().is_some_and(|namespace| { turn_context .config @@ -565,12 +568,9 @@ fn merge_into_namespaces(specs: Vec) -> Vec { continue; }; - namespace.tools.sort_by(|left, right| match (left, right) { - ( - ResponsesApiNamespaceTool::Function(left), - ResponsesApiNamespaceTool::Function(right), - ) => left.name.cmp(&right.name), - }); + namespace + .tools + .sort_by(|left, right| left.name().cmp(right.name())); if namespace.description.trim().is_empty() { namespace.description = default_namespace_description(&namespace.name); @@ -985,12 +985,12 @@ fn append_extension_tool_executors( let mut reserved_tool_names = planned_tools .runtimes() .iter() - .map(|executor| executor.tool_name()) + .map(|executor| executor.tool_name().with_default_namespace()) .collect::>(); let tool_mode = effective_tool_mode(turn_context); if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { - reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); - reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); + reserved_tool_names.insert(ToolName::function(codex_code_mode::PUBLIC_TOOL_NAME)); + reserved_tool_names.insert(ToolName::function(codex_code_mode::WAIT_TOOL_NAME)); } if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) @@ -999,7 +999,7 @@ fn append_extension_tool_executors( .iter() .any(|executor| executor.exposure() == ToolExposure::Deferred) { - reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME)); + reserved_tool_names.insert(ToolName::function(TOOL_SEARCH_TOOL_NAME)); reserved_tool_names.insert(tool_search_tool_name()); } @@ -1007,7 +1007,7 @@ fn append_extension_tool_executors( let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled; for executor in executors.iter().cloned() { - let tool_name = executor.tool_name(); + let tool_name = executor.tool_name().with_default_namespace(); if tool_name == ToolName::namespaced("web", "run") && (!standalone_web_search_enabled || !web_search_mode_on) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 966317d67f..01aeee8d73 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -72,9 +72,7 @@ impl ToolPlanProbe { namespace .tools .iter() - .map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), - }) + .map(|tool| tool.name().to_string()) .collect::>(), )), ToolSpec::Function(_) diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 43076c7bc6..64c0c76429 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -120,6 +120,13 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< Ok(()) } +fn functions_namespace(tools: &[Value]) -> Option<&Value> { + tools.iter().find(|tool| { + tool.get("type").and_then(Value::as_str) == Some("namespace") + && tool.get("name").and_then(Value::as_str) == Some("functions") + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_rejects_oversized_additional_tools_item() -> Result<()> { skip_if_no_network!(Ok(())); @@ -272,6 +279,17 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res assert!(body.get("tools").is_none()); let tools = additional_tools(&body)?; assert!(!tools.is_empty()); + let function_tools = functions_namespace(tools) + .and_then(|namespace| namespace.get("tools")) + .and_then(Value::as_array) + .context("Responses Lite should group default tools under functions")?; + assert!(!function_tools.is_empty()); + assert!(tools.iter().all(|tool| { + !matches!( + tool.get("type").and_then(Value::as_str), + Some("function" | "custom") + ) + })); assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 7228547725..2477d5c81d 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -896,7 +896,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - "item": { "type": "function_call", "call_id": dynamic_call_id, - "namespace": "codex_app", + "namespace": "functions", "name": tool_name, "arguments": tool_call_arguments, } @@ -920,17 +920,11 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - "required": ["mode"], "additionalProperties": false, }); - let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { - name: "codex_app".to_string(), - description: "Automation tools.".to_string(), - tools: vec![DynamicToolNamespaceTool::Function( - DynamicToolFunctionSpec { - name: tool_name.to_string(), - description: tool_description.to_string(), - input_schema: input_schema.clone(), - defer_loading: true, - }, - )], + let dynamic_tool = DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: tool_name.to_string(), + description: tool_description.to_string(), + input_schema: input_schema.clone(), + defer_loading: true, }); let mut builder = test_codex().with_config(configure_search_capable_model); @@ -964,7 +958,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - unreachable!("event guard guarantees DynamicToolCallRequest"); }; assert_eq!(request.call_id, dynamic_call_id); - assert_eq!(request.namespace.as_deref(), Some("codex_app")); + assert_eq!(request.namespace, None); assert_eq!(request.tool, tool_name); assert_eq!(request.arguments, tool_args); @@ -1006,8 +1000,8 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - tools, vec![json!({ "type": "namespace", - "name": "codex_app", - "description": "Automation tools.", + "name": "functions", + "description": "Tools in the functions namespace.", "tools": [{ "type": "function", "name": tool_name, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 963123b59e..0f6de7fd1a 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -19,6 +19,7 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -29,6 +30,7 @@ use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use serde_json::Value; use serde_json::json; +use test_case::test_case; fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) { let raw = req.function_call_output(call_id); assert_eq!( @@ -134,8 +136,11 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res Ok(()) } +#[test_case(None; "omitted_namespace")] +#[test_case(Some(""); "empty_namespace")] +#[test_case(Some("functions"); "explicit_functions_namespace")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { +async fn update_plan_tool_emits_plan_update_event(namespace: Option<&str>) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -158,9 +163,15 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { }) .to_string(); + let function_call = match namespace { + Some(namespace) => { + ev_function_call_with_namespace(call_id, namespace, "update_plan", &plan_args) + } + None => ev_function_call(call_id, "update_plan", &plan_args), + }; let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "update_plan", &plan_args), + function_call, ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -350,9 +361,11 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() *** End Patch"# ); + let mut custom_tool_call = ev_apply_patch_custom_tool_call(call_id, &patch_content); + custom_tool_call["namespace"] = json!("functions"); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_custom_tool_call(call_id, &patch_content), + custom_tool_call, ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 8e5b20e7f5..2d27cc4f48 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -363,7 +363,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/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index e2f5ac666c..64219dcd4a 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -33,7 +33,9 @@ fn uses_reserved_image_gen_namespace() { panic!("imagegen should advertise a namespace tool"); }; assert_eq!(spec.name, IMAGE_GEN_NAMESPACE); - let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0]; + let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0] else { + panic!("imagegen should advertise a function tool"); + }; assert_eq!(function.name, IMAGEGEN_TOOL_NAME); } diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index c50e95d37f..d02c0d5a14 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -7,6 +7,7 @@ mod tool_name; pub use agent_path::AgentPath; 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..34c7849322 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 internally 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)] @@ -26,17 +29,35 @@ impl ToolName { } } + pub fn function(name: impl Into) -> Self { + Self::namespaced(DEFAULT_FUNCTION_NAMESPACE, name) + } + pub fn namespaced(namespace: impl Into, name: impl Into) -> Self { Self { name: name.into(), 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 { + self.namespace + .as_deref() + .is_none_or(|namespace| namespace.is_empty() || namespace == 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/rollout-trace/src/tool_dispatch.rs b/codex-rs/rollout-trace/src/tool_dispatch.rs index 36c6651b10..15f75c033c 100644 --- a/codex-rs/rollout-trace/src/tool_dispatch.rs +++ b/codex-rs/rollout-trace/src/tool_dispatch.rs @@ -7,6 +7,7 @@ use std::fmt::Display; use std::sync::Arc; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::SandboxPermissions; @@ -193,7 +194,10 @@ impl ToolDispatchTraceContext { fn suppresses_tool_dispatch_trace(invocation: &ToolDispatchInvocation) -> bool { matches!(invocation.payload, ToolDispatchPayload::Custom { .. }) - && invocation.tool_namespace.is_none() + && invocation + .tool_namespace + .as_deref() + .is_none_or(|namespace| namespace.is_empty() || namespace == DEFAULT_FUNCTION_NAMESPACE) && invocation.tool_name == codex_code_mode::PUBLIC_TOOL_NAME } @@ -393,16 +397,18 @@ mod tests { #[test] fn suppresses_only_noncanonical_dispatch_boundaries() { - assert!(suppresses_tool_dispatch_trace(&invocation( - codex_code_mode::PUBLIC_TOOL_NAME, - /*tool_namespace*/ None, - ToolDispatchRequester::Model { - model_visible_call_id: "call-exec".to_string(), - }, - ToolDispatchPayload::Custom { - input: "1 + 1".to_string(), - }, - ))); + for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] { + assert!(suppresses_tool_dispatch_trace(&invocation( + codex_code_mode::PUBLIC_TOOL_NAME, + namespace.map(str::to_string), + ToolDispatchRequester::Model { + model_visible_call_id: "call-exec".to_string(), + }, + ToolDispatchPayload::Custom { + input: "1 + 1".to_string(), + }, + ))); + } assert!(!suppresses_tool_dispatch_trace(&invocation( "custom_tool", /*tool_namespace*/ None, diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index ae1848948f..18e1a9b58d 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -42,6 +42,20 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec { tool.description = codex_code_mode::augment_tool_definition(definition).description; } + ResponsesApiNamespaceTool::Freeform(tool) => { + let tool_name = + ToolName::namespaced(namespace.name.clone(), tool.name.clone()); + let definition = CodeModeToolDefinition { + name: code_mode_name_for_tool_name(&tool_name), + tool_name, + description: tool.description.clone(), + kind: CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + }; + tool.description = + codex_code_mode::augment_tool_definition(definition).description; + } } } ToolSpec::Namespace(namespace) @@ -146,6 +160,17 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec { + let tool_name = ToolName::namespaced(namespace.name.clone(), tool.name.clone()); + CodeModeToolDefinition { + name: code_mode_name_for_tool_name(&tool_name), + tool_name, + description: tool.description.clone(), + kind: CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + } + } }) .collect(), ToolSpec::ImageGeneration { .. } @@ -155,6 +180,9 @@ 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/lib.rs b/codex-rs/tools/src/lib.rs index 20f95e4449..9fa234ce81 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -25,6 +25,7 @@ pub use code_mode::code_mode_name_for_tool_name; pub use code_mode::collect_code_mode_exec_prompt_tool_definitions; pub use code_mode::collect_code_mode_tool_definitions; pub use code_mode::tool_spec_to_code_mode_tool_definition; +pub use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; pub use codex_protocol::ToolName; pub use dynamic_tool::parse_dynamic_tool; pub use function_call_error::FunctionCallError; @@ -106,3 +107,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::create_tools_json_for_responses_lite; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index 9911ad0eff..5ddff4b1f2 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -61,9 +61,21 @@ pub fn default_namespace_description(namespace_name: &str) -> String { #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum ResponsesApiNamespaceTool { #[serde(rename = "function")] Function(ResponsesApiTool), + #[serde(rename = "custom")] + Freeform(FreeformTool), +} + +impl ResponsesApiNamespaceTool { + pub fn name(&self) -> &str { + match self { + Self::Function(tool) => &tool.name, + Self::Freeform(tool) => &tool.name, + } + } } pub fn dynamic_tool_to_responses_api_tool( diff --git a/codex-rs/tools/src/tool_search.rs b/codex-rs/tools/src/tool_search.rs index 9a1c8852dd..0ce47a4e22 100644 --- a/codex-rs/tools/src/tool_search.rs +++ b/codex-rs/tools/src/tool_search.rs @@ -1,5 +1,7 @@ +use crate::DEFAULT_FUNCTION_NAMESPACE; use crate::JsonSchema; use crate::LoadableToolSpec; +use crate::ResponsesApiNamespace; use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; use crate::ToolSearchSourceInfo; @@ -36,16 +38,21 @@ 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::Namespace(mut namespace) => { if namespace.description.trim().is_empty() { namespace.description = default_namespace_description(&namespace.name); } for tool in &mut namespace.tools { - let ResponsesApiNamespaceTool::Function(tool) = tool; - tool.defer_loading = Some(true); - tool.output_schema = None; + if let ResponsesApiNamespaceTool::Function(tool) = tool { + tool.defer_loading = Some(true); + tool.output_schema = None; + } } LoadableToolSpec::Namespace(namespace) } @@ -74,8 +81,16 @@ fn default_tool_search_text(spec: &ToolSpec) -> String { push_search_part(&mut parts, namespace.name.clone()); push_search_part(&mut parts, namespace.description.clone()); for tool in &namespace.tools { - let ResponsesApiNamespaceTool::Function(tool) = tool; - append_function_search_text(tool, &mut parts); + match tool { + ResponsesApiNamespaceTool::Function(tool) => { + append_function_search_text(tool, &mut parts); + } + ResponsesApiNamespaceTool::Freeform(tool) => { + push_search_part(&mut parts, tool.name.clone()); + push_search_part(&mut parts, tool.description.clone()); + push_search_part(&mut parts, tool.format.syntax.clone()); + } + } } } ToolSpec::ToolSearch { description, .. } => { diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 146cef3b68..1c988bf363 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -1,8 +1,11 @@ +use crate::DEFAULT_FUNCTION_NAMESPACE; 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::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; @@ -90,6 +93,53 @@ 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 = Vec::new(); + let mut functions_description = None; + let mut functions_index = None; + let mut tools_json = Vec::new(); + + for tool in tools { + match tool { + ToolSpec::Function(tool) => { + functions.push(ResponsesApiNamespaceTool::Function(tool.clone())); + } + ToolSpec::Freeform(tool) => { + functions.push(ResponsesApiNamespaceTool::Freeform(tool.clone())); + } + ToolSpec::Namespace(namespace) if namespace.name == DEFAULT_FUNCTION_NAMESPACE => { + if !namespace.description.trim().is_empty() { + functions_description = Some(namespace.description.clone()); + } + functions.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.is_empty() + { + tools_json.insert( + functions_index, + serde_json::to_value(ToolSpec::Namespace(ResponsesApiNamespace { + name: DEFAULT_FUNCTION_NAMESPACE.to_string(), + description: functions_description + .unwrap_or_else(|| default_namespace_description(DEFAULT_FUNCTION_NAMESPACE)), + tools: functions, + }))?, + ); + } + + Ok(tools_json) +} + #[derive(Debug, Clone, Serialize, PartialEq)] pub struct ResponsesApiWebSearchFilters { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index b6f599d5d5..b1d9b6e3d9 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 codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; @@ -150,6 +151,66 @@ fn create_tools_json_for_responses_api_includes_top_level_name() { ); } +#[test] +fn responses_lite_groups_default_function_and_custom_tools() { + let function = |name: &str| ResponsesApiTool { + name: name.to_string(), + description: String::new(), + strict: false, + defer_loading: None, + parameters: JsonSchema::default(), + output_schema: None, + }; + let tools = create_tools_json_for_responses_lite(&[ + ToolSpec::ToolSearch { + execution: "client".to_string(), + description: "Search tools".to_string(), + parameters: JsonSchema::default(), + }, + ToolSpec::Function(function("lookup_order")), + ToolSpec::Freeform(FreeformTool { + name: "exec".to_string(), + description: String::new(), + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: /.+/".to_string(), + }, + }), + ToolSpec::Namespace(ResponsesApiNamespace { + name: "functions".to_string(), + description: "Default tools".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(function("existing"))], + }), + ]) + .expect("serialize Responses Lite tools"); + + assert_eq!(tools.len(), 2); + assert_eq!(tools[0]["type"], "tool_search"); + assert_eq!(tools[1]["type"], "namespace"); + assert_eq!(tools[1]["name"], "functions"); + assert_eq!(tools[1]["description"], "Default tools"); + let nested_tools = tools[1]["tools"] + .as_array() + .expect("functions namespace should contain tools"); + assert_eq!( + nested_tools + .iter() + .map(|tool| { + ( + tool["type"].as_str().expect("tool type should be a string"), + tool["name"].as_str().expect("tool name should be a string"), + ) + }) + .collect::>(), + vec![ + ("function", "lookup_order"), + ("custom", "exec"), + ("function", "existing"), + ] + ); +} + #[test] fn namespace_tool_spec_serializes_expected_wire_shape() { assert_eq!(