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");