[tool search] support namespaced deferred dynamic tools (#18413)

Deferred dynamic tools need to round-trip a namespace so a tool returned
by `tool_search` can be called through the same registry key that core
uses for dispatch.

This change adds namespace support for dynamic tool specs/calls,
persists it through app-server thread state, and routes dynamic tool
calls by full `ToolName` while still sending the app the leaf tool name.
Deferred dynamic tools must provide a namespace; non-deferred dynamic
tools may remain top-level.

It also introduces `LoadableToolSpec` as the shared
function-or-namespace Responses shape used by both `tool_search` output
and dynamic tool registration, so dynamic tools use the same wrapping
logic in both paths.

Validation:
- `cargo test -p codex-tools`
- `cargo test -p codex-core tool_search`

---------

Co-authored-by: Sayan Sisodiya <sayan@openai.com>
This commit is contained in:
pash-openai
2026-04-20 23:13:08 -07:00
committed by GitHub
Unverified
parent 1dcea729d3
commit dc1a8f2190
60 changed files with 676 additions and 147 deletions
@@ -2662,6 +2662,7 @@ impl CodexMessageProcessor {
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
namespace: tool.namespace,
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
@@ -9476,9 +9477,37 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
if !seen.insert(name.to_string()) {
let namespace = tool.namespace.as_deref().map(str::trim);
if let Some(namespace) = namespace {
if namespace.is_empty() {
return Err(format!(
"dynamic tool namespace must not be empty for {name}"
));
}
if Some(namespace) != tool.namespace.as_deref() {
return Err(format!(
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
));
}
if namespace == "mcp" || namespace.starts_with("mcp__") {
return Err(format!(
"dynamic tool namespace is reserved for {name}: {namespace}"
));
}
}
if !seen.insert((namespace, name)) {
if let Some(namespace) = namespace {
return Err(format!(
"duplicate dynamic tool name in namespace {namespace}: {name}"
));
}
return Err(format!("duplicate dynamic tool name: {name}"));
}
if tool.defer_loading && namespace.is_none() {
return Err(format!(
"deferred dynamic tool must include a namespace: {name}"
));
}
if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) {
return Err(format!(
@@ -10514,6 +10543,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
@@ -10526,6 +10556,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
@@ -10538,6 +10569,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_accepts_nullable_field_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -10553,6 +10585,102 @@ mod tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
ApiDynamicToolSpec {
namespace: Some("other_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
];
let err = validate_dynamic_tools(&tools).expect_err("duplicate name");
assert!(err.contains("codex_app"), "unexpected error: {err}");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_empty_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("empty namespace");
assert!(err.contains("my_tool"), "unexpected error: {err}");
assert!(err.contains("namespace"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_reserved_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("mcp__server__".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("reserved namespace");
assert!(err.contains("my_tool"), "unexpected error: {err}");
assert!(err.contains("reserved"), "unexpected error: {err}");
}
#[test]
fn summary_from_stored_thread_preserves_millisecond_precision() {
let created_at =