Support custom tools in namespaces (#36857)

## What changed

- Allow namespace tool specs to contain custom freeform tools alongside function tools.
- Include namespaced custom tools in deferred tool search and expose them to code mode with names such as `editor__apply_patch`.
- Route custom payloads to matching extension tools while preserving function-only payload validation.

## Testing

- Add serialization, tool search, code-mode definition, and end-to-end dispatch coverage for namespaced custom tools.

GitOrigin-RevId: be64d35f6ae54685c5a9fcf45a732320742ea7e5
This commit is contained in:
rka-oai
2026-08-04 08:28:43 +00:00
committed by copyberry
parent 12288240b4
commit d4fb78bfc5
12 changed files with 409 additions and 28 deletions
@@ -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<ToolInvocation> 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));
+1
View File
@@ -582,6 +582,7 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec<Str
.iter()
.map(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),
ResponsesApiNamespaceTool::Custom(tool) => tool.name.clone(),
})
.collect(),
),
+10 -5
View File
@@ -713,11 +713,16 @@ fn merge_into_namespaces(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
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() {
+4 -1
View File
@@ -84,6 +84,7 @@ impl ToolPlanProbe {
.iter()
.map(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),
ResponsesApiNamespaceTool::Custom(tool) => tool.name.clone(),
})
.collect::<Vec<_>>(),
)),
@@ -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");
+179
View File
@@ -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<Arc<dyn ToolExecutor<ToolCall>>> {
vec![Arc::new(Self)]
}
}
impl ToolExecutor<ToolCall> 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<dyn ToolOutput>)
})
}
}
#[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::<Config>::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<unknown>; };";
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::<Value>(&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::<Value>(&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(()));
+3 -1
View File
@@ -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);
}
+25
View File
@@ -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<CodeModeToolDefin
output_schema: tool.output_schema.clone(),
}
}
ResponsesApiNamespaceTool::Custom(tool) => {
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(),
+38
View File
@@ -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<unknown>; };
);
}
#[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<unknown>; };
```"#
.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!(
+3
View File
@@ -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(
+19 -5
View File
@@ -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, .. } => {
+60
View File
@@ -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
}),
],
})
);
}
+37 -15
View File
@@ -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\"",
},
},
],
})
);