diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b44d0080e4..0db9a99382 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3695,7 +3695,9 @@ dependencies = [ name = "codex-tool-api" version = "0.0.0" dependencies = [ + "codex-protocol", "pretty_assertions", + "serde", "serde_json", "thiserror 2.0.18", ] @@ -3708,6 +3710,7 @@ dependencies = [ "codex-code-mode", "codex-features", "codex-protocol", + "codex-tool-api", "codex-utils-absolute-path", "codex-utils-pty", "pretty_assertions", diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index d41efe895f..c09b39905b 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -3,7 +3,6 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tool_api::ToolError as ExtensionToolError; -use codex_tools::ResponsesApiTool; use codex_tools::ToolName; use codex_tools::ToolSpec; use serde_json::Value; @@ -73,7 +72,7 @@ impl ToolHandler for BundledToolHandler { type Output = BundledToolOutput; fn tool_name(&self) -> ToolName { - ToolName::plain(self.bundle.tool_name()) + self.bundle.tool_name().clone() } fn spec(&self) -> Option { @@ -138,19 +137,6 @@ impl ToolHandler for BundledToolHandler { } } -pub(crate) fn extension_tool_spec( - spec: &codex_tool_api::FunctionToolSpec, -) -> Result { - Ok(ToolSpec::Function(ResponsesApiTool { - name: spec.name.clone(), - description: spec.description.clone(), - strict: spec.strict, - defer_loading: None, - parameters: codex_tools::parse_tool_input_schema(&spec.parameters)?, - output_schema: None, - })) -} - fn map_extension_tool_error(error: ExtensionToolError) -> FunctionCallError { match error { ExtensionToolError::RespondToModel(message) => FunctionCallError::RespondToModel(message), @@ -175,7 +161,6 @@ mod tests { use super::BundledToolHandler; use super::BundledToolOutput; - use super::extension_tool_spec; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -184,6 +169,7 @@ mod tests { use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; + use codex_tools::ToolSpec; struct StubExtensionExecutor; @@ -196,22 +182,27 @@ mod tests { #[tokio::test] async fn exposes_generic_hook_payloads_and_is_conservatively_mutating() { let bundle = codex_tool_api::ToolBundle::new( - codex_tool_api::FunctionToolSpec { - name: "extension_echo".to_string(), - description: "Echoes arguments.".to_string(), - strict: true, - parameters: json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - }, - "required": ["message"], - "additionalProperties": false, - }), - }, + codex_tool_api::ToolName::plain("extension_echo"), + "Echoes arguments.".to_string(), + codex_tool_api::JsonSchema::object( + std::collections::BTreeMap::from([( + "message".to_string(), + codex_tool_api::JsonSchema::string(/*description*/ None), + )]), + Some(vec!["message".to_string()]), + Some(false.into()), + ), Arc::new(StubExtensionExecutor), - ); - let spec = extension_tool_spec(bundle.spec()).expect("extension spec should convert"); + ) + .strict(); + let spec = ToolSpec::Function(codex_tools::ResponsesApiTool { + name: bundle.tool_name().name.clone(), + description: bundle.description().to_string(), + strict: bundle.is_strict(), + defer_loading: bundle.defer_loading().then_some(true), + parameters: bundle.input_schema().clone(), + output_schema: bundle.output_schema().cloned(), + }); let handler = BundledToolHandler::new(bundle, spec); let (session, turn) = crate::session::tests::make_session_and_context().await; let invocation = ToolInvocation { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 5193d15917..589db6c1af 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -17,7 +17,6 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; use crate::tools::handlers::extension_tools::BundledToolHandler; -use crate::tools::handlers::extension_tools::extension_tool_spec; use crate::tools::hook_names::HookToolName; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::util::error_or_panic; @@ -25,8 +24,12 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tools::ConfiguredToolSpec; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_tools::default_namespace_description; use codex_utils_readiness::Readiness; use futures::future::BoxFuture; use serde_json::Value; @@ -512,12 +515,16 @@ impl ToolRegistryBuilder { } } - pub(crate) fn push_spec(&mut self, spec: ToolSpec, supports_parallel_tool_calls: bool) { - let spec = if self.code_mode_enabled { + fn prepare_spec(&self, spec: ToolSpec) -> ToolSpec { + if self.code_mode_enabled { codex_tools::augment_tool_spec_for_code_mode(spec) } else { spec - }; + } + } + + pub(crate) fn push_spec(&mut self, spec: ToolSpec, supports_parallel_tool_calls: bool) { + let spec = self.prepare_spec(spec); self.specs .push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls)); } @@ -542,22 +549,64 @@ impl ToolRegistryBuilder { } pub fn register_tool_bundle(&mut self, bundle: ExtensionToolBundle) { - let tool_name = ToolName::plain(bundle.tool_name()); + let tool_name = bundle.tool_name().clone(); if self.handlers.contains_key(&tool_name) { warn!("Skipping extension tool `{tool_name}`: handler already registered"); return; } - let spec = match extension_tool_spec(bundle.spec()) { - Ok(spec) => spec, - Err(error) => { - error_or_panic(format!( - "failed to convert extension tool `{tool_name}` to a host spec: {error}" - )); - return; - } + let function_tool = ResponsesApiTool { + name: bundle.tool_name().name.clone(), + description: bundle.description().to_string(), + strict: bundle.is_strict(), + defer_loading: bundle.defer_loading().then_some(true), + parameters: bundle.input_schema().clone(), + output_schema: bundle.output_schema().cloned(), }; - self.push_spec(spec.clone(), /*supports_parallel_tool_calls*/ false); + let spec = match bundle.namespace() { + Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { + name: namespace.name().to_string(), + description: namespace + .description() + .map(str::to_string) + .unwrap_or_else(|| default_namespace_description(namespace.name())), + tools: vec![ResponsesApiNamespaceTool::Function(function_tool)], + }), + None => ToolSpec::Function(function_tool), + }; + match self.prepare_spec(spec.clone()) { + ToolSpec::Namespace(mut namespace) => { + if let Some(existing_namespace) = + self.specs.iter_mut().find_map(|configured_tool| { + match &mut configured_tool.spec { + ToolSpec::Namespace(existing_namespace) + if existing_namespace.name == namespace.name => + { + Some(existing_namespace) + } + ToolSpec::Function(_) + | ToolSpec::ToolSearch { .. } + | ToolSpec::LocalShell {} + | ToolSpec::ImageGeneration { .. } + | ToolSpec::WebSearch { .. } + | ToolSpec::Freeform(_) + | ToolSpec::Namespace(_) => None, + } + }) + { + existing_namespace.tools.append(&mut namespace.tools); + } else { + self.specs.push(ConfiguredToolSpec::new( + ToolSpec::Namespace(namespace), + /*supports_parallel_tool_calls*/ false, + )); + } + } + prepared_spec => self.specs.push(ConfiguredToolSpec::new( + prepared_spec, + /*supports_parallel_tool_calls*/ false, + )), + } let handler: Arc = Arc::new(BundledToolHandler::new(bundle, spec)); self.handlers.insert(tool_name, handler); diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 1eacbcf6cb..863b96ec33 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -8,7 +8,6 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::FunctionToolSpec; use codex_extension_api::ToolBundle; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolFuture; @@ -16,6 +15,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_tool_api::JsonSchema; use codex_tool_api::ToolCall as ExtensionToolCall; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; @@ -38,22 +38,40 @@ impl codex_extension_api::ToolContributor for ExtensionEchoContributor { _session_store: &ExtensionData, _thread_store: &ExtensionData, ) -> Vec { - vec![ToolBundle::new( - FunctionToolSpec { - name: "extension_echo".to_string(), - description: "Echoes arguments through an extension tool.".to_string(), - strict: true, - parameters: json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - }, - "required": ["message"], - "additionalProperties": false, - }), - }, - Arc::new(ExtensionEchoExecutor), - )] + vec![ + ToolBundle::new( + ToolName::plain("extension_echo"), + "Echoes arguments through an extension tool.".to_string(), + JsonSchema::object( + std::collections::BTreeMap::from([( + "message".to_string(), + JsonSchema::string(/*description*/ None), + )]), + Some(vec!["message".to_string()]), + Some(false.into()), + ), + Arc::new(ExtensionEchoExecutor), + ) + .strict(), + ToolBundle::new( + ToolName::plain("echo"), + "Echoes arguments through a namespaced extension tool.".to_string(), + JsonSchema::object( + std::collections::BTreeMap::from([( + "message".to_string(), + JsonSchema::string(/*description*/ None), + )]), + Some(vec!["message".to_string()]), + Some(false.into()), + ), + Arc::new(ExtensionEchoExecutor), + ) + .in_namespace( + codex_extension_api::ToolNamespace::new("extension_tools") + .with_description("Extension-owned tools."), + ) + .strict(), + ] } } @@ -345,6 +363,84 @@ async fn extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow:: Ok(()) } +#[tokio::test] +async fn namespaced_extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow::Result<()> +{ + let (mut session, turn) = make_session_and_context().await; + session.services.extensions = extension_tool_test_registry(); + + let router = ToolRouter::from_config( + &turn.tools_config, + ToolRouterParams { + deferred_mcp_tools: None, + mcp_tools: None, + unavailable_called_tools: Vec::new(), + parallel_mcp_server_names: HashSet::new(), + discoverable_tools: None, + extension_tool_bundles: extension_tool_bundles(&session), + dynamic_tools: turn.dynamic_tools.as_slice(), + }, + ); + + assert!( + router + .find_spec(&ToolName::namespaced("extension_tools", "echo")) + .is_some(), + "expected namespaced extension-provided tool spec to be registered" + ); + assert_eq!( + namespace_function_names(&router.model_visible_specs(), "extension_tools"), + vec!["echo".to_string()] + ); + + let call = ToolRouter::build_tool_call( + &session, + ResponseItem::FunctionCall { + id: None, + name: "echo".to_string(), + namespace: Some("extension_tools".to_string()), + arguments: json!({ "message": "hello" }).to_string(), + call_id: "call-extension-namespace".to_string(), + }, + ) + .await? + .expect("function_call should produce a tool call"); + + let result = router + .dispatch_tool_call_with_code_mode_result( + Arc::new(session), + Arc::new(turn), + CancellationToken::new(), + Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call, + ToolCallSource::Direct, + ) + .await?; + + let response = result.into_response(); + match response { + ResponseInputItem::FunctionCallOutput { call_id, output } => { + assert_eq!(call_id, "call-extension-namespace"); + let FunctionCallOutputBody::Text(text) = output.body else { + panic!("expected text function call output") + }; + let value: serde_json::Value = + serde_json::from_str(&text).expect("extension tool output should be json"); + assert_eq!( + value, + json!({ + "arguments": { "message": "hello" }, + "callId": "call-extension-namespace", + "ok": true, + }) + ); + } + other => panic!("expected function call output, got {other:?}"), + } + + Ok(()) +} + fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec { specs .iter() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 13a79e5943..070f8830b8 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -74,7 +74,7 @@ pub fn build_tool_registry_builder( let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; if config.code_mode_enabled { - let namespace_descriptions = params + let mut namespace_descriptions = params .tool_namespaces .into_iter() .flatten() @@ -88,6 +88,17 @@ pub fn build_tool_registry_builder( ) }) .collect::>(); + for bundle in params.extension_tool_bundles { + let Some(namespace) = bundle.namespace() else { + continue; + }; + namespace_descriptions + .entry(namespace.name().to_string()) + .or_insert_with(|| codex_code_mode::ToolNamespaceDescription { + name: namespace.name().to_string(), + description: namespace.description().unwrap_or_default().to_string(), + }); + } let nested_config = config.for_code_mode_nested_tools(); let nested_builder = build_tool_registry_builder( &nested_config, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 7cd33a2aaf..07a3af02ee 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -43,7 +43,6 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tool_api::FunctionToolSpec; use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tool_api::ToolExecutor; use codex_tool_api::ToolFuture; @@ -87,22 +86,30 @@ impl ToolExecutor for UnusedExtensionExecutor { fn extension_tool_bundle(name: &str, description: &str) -> ExtensionToolBundle { ExtensionToolBundle::new( - FunctionToolSpec { - name: name.to_string(), - description: description.to_string(), - strict: true, - parameters: serde_json::to_value(JsonSchema::object( - BTreeMap::from([( - "message".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["message".to_string()]), - Some(false.into()), - )) - .expect("extension schema should serialize"), - }, + ToolName::plain(name), + description.to_string(), + JsonSchema::object( + BTreeMap::from([( + "message".to_string(), + JsonSchema::string(/*description*/ None), + )]), + Some(vec!["message".to_string()]), + Some(false.into()), + ), std::sync::Arc::new(UnusedExtensionExecutor), ) + .strict() +} + +fn namespaced_extension_tool_bundle( + name: &str, + description: &str, + namespace: &str, + namespace_description: &str, +) -> ExtensionToolBundle { + extension_tool_bundle(name, description).in_namespace( + codex_tool_api::ToolNamespace::new(namespace).with_description(namespace_description), + ) } #[test] @@ -145,6 +152,53 @@ fn extension_tools_do_not_replace_builtin_tools() { ); } +#[test] +fn namespaced_extension_tools_coalesce_into_namespace_specs() { + let model_info = model_info(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &Features::with_defaults(), + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let extension_tool_bundles = vec![ + namespaced_extension_tool_bundle( + "echo", + "Echoes extension arguments.", + "extension_tools", + "Extension-owned tools.", + ), + namespaced_extension_tool_bundle( + "status", + "Returns extension status.", + "extension_tools", + "Extension-owned tools.", + ), + ]; + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + /*discoverable_tools*/ None, + &extension_tool_bundles, + &[], + ); + + assert_eq!( + namespace_function_names(&tools, "extension_tools"), + vec!["echo".to_string(), "status".to_string()] + ); + let ToolSpec::Namespace(namespace) = &find_tool(&tools, "extension_tools").spec else { + panic!("expected extension namespace tool"); + }; + assert_eq!(namespace.description, "Extension-owned tools."); +} + #[test] fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { let model_info = model_info(); @@ -2302,6 +2356,46 @@ fn code_mode_only_exec_description_includes_extension_tool_details() { assert!(description.contains("Echoes arguments through an extension tool.")); } +#[test] +fn code_mode_only_exec_description_includes_extension_namespace_guidance() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let extension_tool_bundles = vec![namespaced_extension_tool_bundle( + "echo", + "Echoes arguments through a namespaced extension tool.", + "extension_tools", + "Extension-owned tools.", + )]; + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + /*discoverable_tools*/ None, + &extension_tool_bundles, + &[], + ); + let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec + else { + panic!("expected freeform tool"); + }; + + assert!(description.contains("## extension_tools\nExtension-owned tools.")); +} + #[test] fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() { let model_info = model_info(); diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 7c9e412fd6..697bc32e49 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -2,12 +2,13 @@ mod contributors; mod registry; mod state; -pub use codex_tool_api::FunctionToolSpec; pub use codex_tool_api::ToolBundle; pub use codex_tool_api::ToolCall; pub use codex_tool_api::ToolError; pub use codex_tool_api::ToolExecutor; pub use codex_tool_api::ToolFuture; +pub use codex_tool_api::ToolName; +pub use codex_tool_api::ToolNamespace; pub use contributors::ApprovalInterceptorContributor; pub use contributors::ContextContributor; pub use contributors::PromptFragment; diff --git a/codex-rs/tool-api/Cargo.toml b/codex-rs/tool-api/Cargo.toml index 13d1a22071..1c63ff23ee 100644 --- a/codex-rs/tool-api/Cargo.toml +++ b/codex-rs/tool-api/Cargo.toml @@ -13,6 +13,8 @@ doctest = false workspace = true [dependencies] +codex-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/tool-api/README.md b/codex-rs/tool-api/README.md index 418c1d177c..c15f5645f4 100644 --- a/codex-rs/tool-api/README.md +++ b/codex-rs/tool-api/README.md @@ -8,8 +8,9 @@ Crates that define contributed tools should depend on this crate. It owns: - the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`, and `ToolError` -- the one model-visible spec an extension may contribute directly: - `FunctionToolSpec` +- the shared model-visible function-tool fields stored directly on + `ToolBundle` +- contributed namespace metadata through `ToolNamespace` The contract is intentionally narrow: contributed tools receive a call id plus raw JSON arguments and return a JSON value. If a feature needs richer host @@ -23,7 +24,7 @@ tool-owning extension crate --> codex-tool-api <-- codex-core ``` `codex-tools` has a different job. It remains the host-side owner of Responses -API tool models, schema parsing, namespaces, discovery, MCP/dynamic conversion, -code-mode shaping, and other aggregate host concerns. A crate that only wants -to contribute one ordinary function tool through an extension should not need -to depend on `codex-tools`. +API tool models, namespace rendering and aggregation, discovery, MCP/dynamic +conversion, code-mode shaping, and other aggregate host concerns. A crate that +only wants to contribute one ordinary function tool through an extension should +not need to depend on `codex-tools`. diff --git a/codex-rs/tool-api/src/bundle.rs b/codex-rs/tool-api/src/bundle.rs index 18c84c4c07..8b81e3e39a 100644 --- a/codex-rs/tool-api/src/bundle.rs +++ b/codex-rs/tool-api/src/bundle.rs @@ -4,9 +4,11 @@ use std::sync::Arc; use serde_json::Value; -use crate::FunctionToolSpec; +use crate::JsonSchema; use crate::ToolCall; use crate::ToolError; +use crate::ToolName; +use crate::ToolNamespace; /// Future returned by one contributed function-tool invocation. pub type ToolFuture<'a> = Pin> + Send + 'a>>; @@ -15,24 +17,98 @@ pub type ToolFuture<'a> = Pin> /// function tool. #[derive(Clone)] pub struct ToolBundle { - spec: FunctionToolSpec, + tool_name: ToolName, + namespace: Option, + description: String, + input_schema: JsonSchema, + strict: bool, + output_schema: Option, + defer_loading: bool, executor: Arc, } impl ToolBundle { /// Creates one contributed function-tool bundle. - pub fn new(spec: FunctionToolSpec, executor: Arc) -> Self { - Self { spec, executor } + pub fn new( + tool_name: ToolName, + description: String, + input_schema: JsonSchema, + executor: Arc, + ) -> Self { + let namespace = tool_name + .namespace + .as_ref() + .map(|namespace| ToolNamespace::new(namespace.clone())); + Self { + tool_name, + namespace, + description, + input_schema, + strict: false, + output_schema: None, + defer_loading: false, + executor, + } } - /// Returns the contributed function-tool spec. - pub fn spec(&self) -> &FunctionToolSpec { - &self.spec + /// Enables strict schema handling for this contributed tool. + pub fn strict(mut self) -> Self { + self.strict = true; + self + } + + /// Adds a model-visible output schema for this contributed tool. + pub fn with_output_schema(mut self, output_schema: Value) -> Self { + self.output_schema = Some(output_schema); + self + } + + /// Marks this contributed tool as loadable on demand. + pub fn deferred(mut self) -> Self { + self.defer_loading = true; + self + } + + /// Places this contributed tool in a model-visible namespace. + pub fn in_namespace(mut self, namespace: ToolNamespace) -> Self { + self.tool_name.namespace = Some(namespace.name().to_string()); + self.namespace = Some(namespace); + self } /// Returns the contributed function-tool name. - pub fn tool_name(&self) -> &str { - self.spec.name.as_str() + pub fn tool_name(&self) -> &ToolName { + &self.tool_name + } + + /// Returns the contributed tool namespace, if any. + pub fn namespace(&self) -> Option<&ToolNamespace> { + self.namespace.as_ref() + } + + /// Returns the contributed function-tool description. + pub fn description(&self) -> &str { + self.description.as_str() + } + + /// Returns the contributed function-tool input schema. + pub fn input_schema(&self) -> &JsonSchema { + &self.input_schema + } + + /// Returns whether strict schema handling is enabled. + pub fn is_strict(&self) -> bool { + self.strict + } + + /// Returns the optional contributed function-tool output schema. + pub fn output_schema(&self) -> Option<&Value> { + self.output_schema.as_ref() + } + + /// Returns whether the contributed function tool should be loadable on demand. + pub fn defer_loading(&self) -> bool { + self.defer_loading } /// Returns the executable implementation. @@ -59,8 +135,10 @@ mod tests { use super::ToolBundle; use super::ToolExecutor; use super::ToolFuture; - use crate::FunctionToolSpec; + use crate::JsonSchema; use crate::ToolCall; + use crate::ToolName; + use crate::ToolNamespace; struct StubExecutor; @@ -71,17 +149,72 @@ mod tests { } #[test] - fn bundle_derives_name_from_function_spec() { + fn bundle_preserves_plain_tool_name() { let bundle = ToolBundle::new( - FunctionToolSpec { - name: "echo".to_string(), - description: "Echo arguments.".to_string(), - strict: false, - parameters: json!({ "type": "object" }), - }, + ToolName::plain("echo"), + "Echo arguments.".to_string(), + JsonSchema::object( + std::collections::BTreeMap::new(), + /*required*/ None, + Some(true.into()), + ), Arc::new(StubExecutor), ); - assert_eq!(bundle.tool_name(), "echo"); + assert_eq!(bundle.tool_name(), &ToolName::plain("echo")); + assert_eq!(bundle.namespace(), None); + } + + #[test] + fn bundle_derives_namespace_from_namespaced_tool_name() { + let bundle = ToolBundle::new( + ToolName::namespaced("extension_tools/", "echo"), + "Echo arguments.".to_string(), + JsonSchema::object( + std::collections::BTreeMap::new(), + /*required*/ None, + Some(true.into()), + ), + Arc::new(StubExecutor), + ); + + assert_eq!( + bundle.tool_name(), + &ToolName::namespaced("extension_tools/", "echo") + ); + assert_eq!( + bundle.namespace(), + Some(&ToolNamespace::new("extension_tools/")) + ); + } + + #[test] + fn bundle_sets_tool_name_when_namespace_metadata_is_attached() { + let bundle = ToolBundle::new( + ToolName::plain("echo"), + "Echo arguments.".to_string(), + JsonSchema::object( + std::collections::BTreeMap::new(), + /*required*/ None, + Some(true.into()), + ), + Arc::new(StubExecutor), + ) + .in_namespace( + ToolNamespace::new("extension_tools/") + .with_description("Extension-owned function tools."), + ); + + assert_eq!( + bundle.tool_name(), + &ToolName::namespaced("extension_tools/", "echo") + ); + assert_eq!( + bundle.namespace(), + Some( + &ToolNamespace::new("extension_tools/") + .with_description("Extension-owned function tools.") + ) + ); } } diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tool-api/src/json_schema.rs similarity index 100% rename from codex-rs/tools/src/json_schema.rs rename to codex-rs/tool-api/src/json_schema.rs diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tool-api/src/json_schema_tests.rs similarity index 100% rename from codex-rs/tools/src/json_schema_tests.rs rename to codex-rs/tool-api/src/json_schema_tests.rs diff --git a/codex-rs/tool-api/src/lib.rs b/codex-rs/tool-api/src/lib.rs index 11788f5649..97a71b2671 100644 --- a/codex-rs/tool-api/src/lib.rs +++ b/codex-rs/tool-api/src/lib.rs @@ -4,11 +4,18 @@ mod bundle; mod call; mod error; -mod spec; +mod json_schema; +mod namespace; pub use bundle::ToolBundle; pub use bundle::ToolExecutor; pub use bundle::ToolFuture; pub use call::ToolCall; +pub use codex_protocol::ToolName; pub use error::ToolError; -pub use spec::FunctionToolSpec; +pub use json_schema::AdditionalProperties; +pub use json_schema::JsonSchema; +pub use json_schema::JsonSchemaPrimitiveType; +pub use json_schema::JsonSchemaType; +pub use json_schema::parse_tool_input_schema; +pub use namespace::ToolNamespace; diff --git a/codex-rs/tool-api/src/namespace.rs b/codex-rs/tool-api/src/namespace.rs new file mode 100644 index 0000000000..cf7bb91570 --- /dev/null +++ b/codex-rs/tool-api/src/namespace.rs @@ -0,0 +1,36 @@ +/// Model-visible namespace metadata for a contributed tool bundle. +/// +/// The namespace name participates in the callable tool identity, while the +/// optional description is host-visible metadata used when rendering namespace +/// specs for the model. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ToolNamespace { + name: String, + description: Option, +} + +impl ToolNamespace { + /// Creates namespace metadata with no explicit description. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + description: None, + } + } + + /// Sets the model-visible namespace description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Returns the callable namespace name. + pub fn name(&self) -> &str { + self.name.as_str() + } + + /// Returns the optional model-visible namespace description. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} diff --git a/codex-rs/tool-api/src/spec.rs b/codex-rs/tool-api/src/spec.rs deleted file mode 100644 index 1b25837bb3..0000000000 --- a/codex-rs/tool-api/src/spec.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serde_json::Value; - -/// Model-visible definition for one contributed function tool. -#[derive(Debug, Clone, PartialEq)] -pub struct FunctionToolSpec { - pub name: String, - pub description: String, - pub strict: bool, - pub parameters: Value, -} diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 0029352d4b..6d9cea4f6f 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -12,6 +12,7 @@ codex-app-server-protocol = { workspace = true } codex-code-mode = { workspace = true } codex-features = { workspace = true } codex-protocol = { workspace = true } +codex-tool-api = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index d0a1794cbc..51caf710d9 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -4,7 +4,6 @@ mod code_mode; mod dynamic_tool; mod image_detail; -mod json_schema; mod mcp_tool; mod request_plugin_install; mod responses_api; @@ -19,15 +18,15 @@ 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::ToolName; +pub use codex_tool_api::AdditionalProperties; +pub use codex_tool_api::JsonSchema; +pub use codex_tool_api::JsonSchemaPrimitiveType; +pub use codex_tool_api::JsonSchemaType; +pub use codex_tool_api::parse_tool_input_schema; pub use dynamic_tool::parse_dynamic_tool; pub use image_detail::can_request_original_image_detail; pub use image_detail::normalize_output_image_detail; pub use image_detail::sanitize_original_image_detail; -pub use json_schema::AdditionalProperties; -pub use json_schema::JsonSchema; -pub use json_schema::JsonSchemaPrimitiveType; -pub use json_schema::JsonSchemaType; -pub use json_schema::parse_tool_input_schema; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE;