diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 2f799e912a..52eb8da899 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; +use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironment; use codex_tools::ToolName; @@ -61,7 +62,23 @@ impl ToolExecutor for ExtensionToolAdapter { impl CoreToolRuntime for ExtensionToolAdapter { fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) + match payload { + ToolPayload::Function { .. } => true, + ToolPayload::Custom { .. } => match self.0.spec() { + ToolSpec::Freeform(_) => true, + ToolSpec::Namespace(namespace) => namespace.tools.iter().any(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Custom(tool) + if tool.name == self.0.tool_name().name + ) + }), + ToolSpec::Function(_) + | ToolSpec::ToolSearch { .. } + | ToolSpec::WebSearch { .. } => false, + }, + ToolPayload::ToolSearch { .. } => false, + } } } @@ -286,6 +303,18 @@ mod tests { } } + #[test] + fn function_extensions_reject_custom_payloads() { + let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor)); + + assert!(handler.matches_kind(&ToolPayload::Function { + arguments: "{}".to_string(), + })); + assert!(!handler.matches_kind(&ToolPayload::Custom { + input: "raw input".to_string(), + })); + } + #[tokio::test] async fn exposes_generic_hook_payloads() { let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor)); diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 4549eac0b3..251d2825ed 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -582,6 +582,7 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec tool.name.clone(), + ResponsesApiNamespaceTool::Custom(tool) => tool.name.clone(), }) .collect(), ), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 0dbb5c0580..20ef7a1249 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -713,11 +713,16 @@ 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| { + let left_name = match left { + ResponsesApiNamespaceTool::Function(tool) => &tool.name, + ResponsesApiNamespaceTool::Custom(tool) => &tool.name, + }; + let right_name = match right { + ResponsesApiNamespaceTool::Function(tool) => &tool.name, + ResponsesApiNamespaceTool::Custom(tool) => &tool.name, + }; + left_name.cmp(right_name) }); if namespace.description.trim().is_empty() { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 5d5adac913..4bd8244ae7 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -84,6 +84,7 @@ impl ToolPlanProbe { .iter() .map(|tool| match tool { ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), + ResponsesApiNamespaceTool::Custom(tool) => tool.name.clone(), }) .collect::>(), )), @@ -1666,7 +1667,9 @@ async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else { panic!("expected direct-only namespace spec"); }; - let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0]; + let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0] else { + panic!("expected direct-only namespace function tool"); + }; assert_eq!(tool.defer_loading, None); let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { panic!("expected code mode exec tool"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 1056b2f664..5f8d545167 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -8,7 +8,9 @@ use codex_config::types::McpServerTransportConfig; use codex_core::StartThreadOptions; use codex_core::config::Config; use codex_core::config::CurrentTimeReminderConfig; +use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ToolContributor; use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_login::CodexAuth; @@ -27,6 +29,19 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use codex_tools::FreeformTool; +use codex_tools::FreeformToolFormat; +use codex_tools::FunctionCallError; +use codex_tools::JsonToolOutput; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolCall; +use codex_tools::ToolExecutor; +use codex_tools::ToolExecutorFuture; +use codex_tools::ToolName; +use codex_tools::ToolOutput; +use codex_tools::ToolPayload; +use codex_tools::ToolSpec; use codex_web_search_extension::install as install_web_search_extension; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::AppsTestToolLoading; @@ -4035,6 +4050,170 @@ text(JSON.stringify({ Ok(()) } +struct NamespacedCustomTool; + +impl ToolContributor for NamespacedCustomTool { + fn tools( + &self, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + ) -> Vec>> { + vec![Arc::new(Self)] + } +} + +impl ToolExecutor for NamespacedCustomTool { + fn tool_name(&self) -> ToolName { + ToolName::namespaced("editor", "apply_patch") + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Namespace(ResponsesApiNamespace { + name: "editor".to_string(), + description: "Editing tools.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Custom(FreeformTool { + name: "apply_patch".to_string(), + description: "Apply a raw editor patch.".to_string(), + defer_loading: None, + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: /.+/".to_string(), + }, + })], + }) + } + + fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> { + Box::pin(async move { + let ToolPayload::Custom { input } = call.payload else { + return Err(FunctionCallError::Fatal( + "expected custom tool payload".to_string(), + )); + }; + Ok(Box::new(JsonToolOutput::new(serde_json::json!({ + "namespace": call.tool_name.namespace, + "name": call.tool_name.name, + "input": input, + }))) as Box) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exposes_and_dispatches_namespaced_custom_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::::new(); + extensions.tool_contributor(Arc::new(NamespacedCustomTool)); + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_extensions(Arc::new(extensions.build())) + .with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let code = r#" +const tool = ALL_TOOLS.find(({ name }) => name === "editor__apply_patch"); +const result = await tools.editor__apply_patch("nested patch"); +text(JSON.stringify({ + name: tool?.name ?? null, + description: tool?.description ?? null, + result, +})); +"#; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + responses::ev_custom_tool_call_with_namespace( + "call-direct", + "editor", + "apply_patch", + "direct patch", + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_custom_tool_call("call-exec", "exec", code), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + test.submit_turn("call the namespaced custom editor tool directly and through exec") + .await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + + let declaration = + "declare const tools: { editor__apply_patch(input: string): Promise; };"; + let description = + format!("Apply a raw editor patch.\n\nexec tool declaration:\n```ts\n{declaration}\n```"); + let first_body = requests[0].body_json(); + let namespaced_custom_tool = namespace_child_tool(&first_body, "editor", "apply_patch") + .expect("namespaced custom tool should be included in the model request"); + assert_eq!( + namespaced_custom_tool, + &serde_json::json!({ + "type": "custom", + "name": "apply_patch", + "description": description, + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/", + }, + }) + ); + + let (direct_output, direct_success) = + custom_tool_output_body_and_success(&requests[1], "call-direct"); + assert_ne!(direct_success, Some(false)); + let direct_output = serde_json::from_str::(&direct_output).unwrap_or_else(|error| { + panic!("invalid direct custom tool output `{direct_output}`: {error}") + }); + assert_eq!( + direct_output, + serde_json::json!({ + "namespace": "editor", + "name": "apply_patch", + "input": "direct patch", + }) + ); + + let (exec_output, exec_success) = + custom_tool_output_body_and_success(&requests[2], "call-exec"); + assert_ne!(exec_success, Some(false)); + let exec_output = serde_json::from_str::(&exec_output).unwrap_or_else(|error| { + panic!("invalid code mode custom tool output `{exec_output}`: {error}") + }); + assert_eq!( + exec_output, + serde_json::json!({ + "name": "editor__apply_patch", + "description": format!("Editing tools.\n\n{description}"), + "result": { + "namespace": "editor", + "name": "apply_patch", + "input": "nested patch", + }, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_exposes_namespaced_mcp_tools_on_global_tools_object() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index d7543b33b3..7e6672e7cf 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -49,7 +49,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/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index f9a88f83d0..8dc7cfc6b0 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::Custom(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::ToolSearch { .. } | ToolSpec::WebSearch { .. } => Vec::new(), diff --git a/codex-rs/tools/src/code_mode_tests.rs b/codex-rs/tools/src/code_mode_tests.rs index fbc013f5e8..bf5db21bfc 100644 --- a/codex-rs/tools/src/code_mode_tests.rs +++ b/codex-rs/tools/src/code_mode_tests.rs @@ -4,6 +4,8 @@ use crate::AdditionalProperties; use crate::FreeformTool; use crate::FreeformToolFormat; use crate::JsonSchema; +use crate::ResponsesApiNamespace; +use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; use crate::ToolName; use crate::ToolSpec; @@ -123,6 +125,42 @@ declare const tools: { apply_patch(input: string): Promise; }; ); } +#[test] +fn tool_spec_to_code_mode_tool_definition_supports_namespaced_custom_tools() { + let spec = ToolSpec::Namespace(ResponsesApiNamespace { + name: "editor".to_string(), + description: "Editing tools".to_string(), + tools: vec![ResponsesApiNamespaceTool::Custom(FreeformTool { + name: "apply_patch".to_string(), + description: "Apply a patch".to_string(), + defer_loading: None, + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: \"patch\"".to_string(), + }, + })], + }); + + assert_eq!( + tool_spec_to_code_mode_tool_definition(&spec), + Some(codex_code_mode::ToolDefinition { + name: "editor__apply_patch".to_string(), + tool_name: ToolName::namespaced("editor", "apply_patch"), + description: r#"Apply a patch + +exec tool declaration: +```ts +declare const tools: { editor__apply_patch(input: string): Promise; }; +```"# + .to_string(), + kind: codex_code_mode::CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + }) + ); +} + #[test] fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() { assert_eq!( diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index b0ca9b69eb..f7e4cc182b 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -63,9 +63,12 @@ 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")] + Custom(FreeformTool), } 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 2bde910199..81347b3e60 100644 --- a/codex-rs/tools/src/tool_search.rs +++ b/codex-rs/tools/src/tool_search.rs @@ -43,9 +43,15 @@ impl ToolSearchInfo { 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; + match tool { + ResponsesApiNamespaceTool::Function(tool) => { + tool.defer_loading = Some(true); + tool.output_schema = None; + } + ResponsesApiNamespaceTool::Custom(tool) => { + tool.defer_loading = Some(true); + } + } } LoadableToolSpec::Namespace(namespace) } @@ -73,8 +79,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::Custom(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_search_tests.rs b/codex-rs/tools/src/tool_search_tests.rs index bf910c57f1..86c1db16ef 100644 --- a/codex-rs/tools/src/tool_search_tests.rs +++ b/codex-rs/tools/src/tool_search_tests.rs @@ -46,3 +46,63 @@ fn default_search_text_uses_model_visible_namespace_metadata_once() { "codex_app Manage Codex automations. automation_update automation update Create or update automations. Automation options. mode Update mode. schedule Schedule settings. timezone IANA timezone." ); } + +#[test] +fn mixed_namespaced_function_and_custom_tools_are_searchable() { + 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 custom_tool = crate::FreeformTool { + name: "apply_patch".to_string(), + description: "Apply a patch".to_string(), + defer_loading: None, + format: crate::FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: \"patch\"".to_string(), + }, + }; + let spec = ToolSpec::Namespace(crate::ResponsesApiNamespace { + name: "editor".to_string(), + description: "Editing tools".to_string(), + tools: vec![ + ResponsesApiNamespaceTool::Function(function_tool.clone()), + ResponsesApiNamespaceTool::Custom(custom_tool.clone()), + ], + }); + + let search_info = ToolSearchInfo::from_tool_spec(spec, /*source_info*/ None) + .expect("mixed namespace should be searchable"); + + assert_eq!( + search_info.entry.search_text, + "editor Editing tools lookup_order lookup order Look up an order apply_patch Apply a patch lark" + ); + assert_eq!( + search_info.entry.output, + LoadableToolSpec::Namespace(crate::ResponsesApiNamespace { + name: "editor".to_string(), + description: "Editing tools".to_string(), + tools: vec![ + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + defer_loading: Some(true), + output_schema: None, + ..function_tool + }), + ResponsesApiNamespaceTool::Custom(crate::FreeformTool { + defer_loading: Some(true), + ..custom_tool + }), + ], + }) + ); +} diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 3bd7d8ac6b..9b59f780e5 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -174,21 +174,33 @@ fn namespace_tool_spec_serializes_expected_wire_shape() { serde_json::to_value(ToolSpec::Namespace(ResponsesApiNamespace { name: "mcp__demo__".to_string(), description: "Demo tools".to_string(), - tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: "lookup_order".to_string(), - description: "Look up an order".to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - BTreeMap::from([( - "order_id".to_string(), - JsonSchema::string(/*description*/ None), - )]), - /*required*/ None, - /*additional_properties*/ None, - ), - output_schema: None, - })], + tools: vec![ + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::from([( + "order_id".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + output_schema: None, + }), + ResponsesApiNamespaceTool::Custom(FreeformTool { + name: "apply_patch".to_string(), + description: "Apply a patch".to_string(), + defer_loading: None, + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: \"patch\"".to_string(), + }, + }), + ], })) .expect("serialize namespace tool"), json!({ @@ -208,6 +220,16 @@ fn namespace_tool_spec_serializes_expected_wire_shape() { }, }, }, + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: \"patch\"", + }, + }, ], }) );