Expose explicit dynamic tool namespaces in thread start (#27371)

Stacked on #27365.

## Stack note

[#27365](https://github.com/openai/codex/pull/27365) kept `thread/start`
unchanged and converted its input in `thread_processor`. This PR updates
`thread/start` to accept explicit functions and namespaces directly.

Legacy per-tool arrays are still accepted and converted while reading
the request. As a result, `thread_processor` can validate and pass the
tools through directly, which is why some code added in #27365 is
removed here.

## Why

`thread/start.dynamicTools` still repeats namespace data on each
function even though core now stores explicit namespace groups. The
request API should use the same shape so each namespace has one
description and one member list.

## What changed

- Accept top-level functions and explicit namespace objects in
`dynamicTools`.
- Continue accepting fully legacy flat arrays, including
`exposeToContext`.
- Reject arrays that mix legacy and canonical entries.
- Reuse the protocol types directly and remove the temporary app-server
adapter.
- Update validation, docs, the test client, and generated schemas.

## Test plan

- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
dynamic_tool_call_round_trip_sends_text_content_items_to_model`
- `just test -p codex-app-server
thread_start_normalizes_legacy_dynamic_tools_into_model_request`
- `just test -p codex-app-server
thread_start_rejects_mixed_dynamic_tool_formats`
- `just test -p codex-app-server
thread_start_rejects_hidden_dynamic_tools_without_namespace`
This commit is contained in:
sayan-oai
2026-06-15 08:35:57 -07:00
committed by GitHub
Unverified
parent 42ad752f36
commit 11faf9af94
19 changed files with 840 additions and 525 deletions
@@ -54,7 +54,9 @@ use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::DynamicToolFunctionSpec;
use codex_app_server_protocol::DynamicToolNamespaceTool;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::EnvironmentAddParams;
use codex_app_server_protocol::EnvironmentAddResponse;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
@@ -377,8 +379,6 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::group_dynamic_tools_by_namespace;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
@@ -195,9 +195,10 @@ fn has_model_resume_override(
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
}
fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
const DYNAMIC_TOOL_NAMESPACE_DESCRIPTION_MAX_LEN: usize = 1024;
const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$";
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
"api_tool",
@@ -243,8 +244,11 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
Ok(())
}
let mut seen = HashSet::new();
for tool in tools {
fn validate_dynamic_tool<'a>(
tool: &'a DynamicToolFunctionSpec,
namespace: Option<&str>,
seen: &mut HashSet<&'a str>,
) -> Result<(), String> {
let name = tool.name.trim();
if name.is_empty() {
return Err("dynamic tool name must not be empty".to_string());
@@ -259,37 +263,7 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
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}",
name = escape_identifier_for_error(name),
namespace = escape_identifier_for_error(namespace),
));
}
validate_dynamic_tool_identifier(
namespace,
"dynamic tool namespace",
DYNAMIC_TOOL_NAMESPACE_MAX_LEN,
)?;
if namespace == "mcp" || namespace.starts_with("mcp__") {
return Err(format!(
"dynamic tool namespace is reserved for {name}: {namespace}"
));
}
if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) {
return Err(format!(
"dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}",
));
}
}
if !seen.insert((namespace, name)) {
if !seen.insert(name) {
if let Some(namespace) = namespace {
return Err(format!(
"duplicate dynamic tool name in namespace {namespace}: {name}"
@@ -308,6 +282,62 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
"dynamic tool input schema is not supported for {name}: {err}"
));
}
Ok(())
}
let mut seen_tools = HashSet::new();
let mut seen_namespaces = HashSet::new();
for spec in tools {
match spec {
DynamicToolSpec::Function(tool) => {
validate_dynamic_tool(tool, /*namespace*/ None, &mut seen_tools)?;
}
DynamicToolSpec::Namespace(namespace) => {
let name = namespace.name.trim();
if name.is_empty() {
return Err("dynamic tool namespace must not be empty".to_string());
}
if name != namespace.name {
return Err(format!(
"dynamic tool namespace has leading/trailing whitespace: {}",
escape_identifier_for_error(&namespace.name),
));
}
validate_dynamic_tool_identifier(
name,
"dynamic tool namespace",
DYNAMIC_TOOL_NAMESPACE_MAX_LEN,
)?;
if namespace.description.chars().count()
> DYNAMIC_TOOL_NAMESPACE_DESCRIPTION_MAX_LEN
{
return Err(format!(
"dynamic tool namespace description must be at most {DYNAMIC_TOOL_NAMESPACE_DESCRIPTION_MAX_LEN} characters"
));
}
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool namespace is reserved: {name}"));
}
if RESERVED_RESPONSES_NAMESPACES.contains(&name) {
return Err(format!(
"dynamic tool namespace collides with a reserved Responses API namespace: {name}",
));
}
if !seen_namespaces.insert(name) {
return Err(format!("duplicate dynamic tool namespace: {name}"));
}
if namespace.tools.is_empty() {
return Err(format!(
"dynamic tool namespace must contain at least one tool: {name}"
));
}
let mut seen_namespace_tools = HashSet::new();
for tool in &namespace.tools {
let DynamicToolNamespaceTool::Function(tool) = tool;
validate_dynamic_tool(tool, Some(name), &mut seen_namespace_tools)?;
}
}
}
}
Ok(())
}
@@ -990,7 +1020,7 @@ impl ThreadRequestProcessor {
app_server_client_version: Option<String>,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
@@ -1077,29 +1107,17 @@ impl ThreadRequestProcessor {
.default_environment_selections(&config.cwd)
});
let dynamic_tools = dynamic_tools.unwrap_or_default();
// Count callable tools before grouping changes the outer list length.
let core_dynamic_tool_count = dynamic_tools.len();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
} else {
if !dynamic_tools.is_empty() {
validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?;
// Normalize the flat app-server input into core's function and namespace types.
let tools = dynamic_tools
.into_iter()
.map(|tool| {
(
tool.namespace,
DynamicToolFunctionSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
},
)
})
.collect();
group_dynamic_tools_by_namespace(tools)
};
}
// Count callable functions rather than top-level namespace containers.
let dynamic_tool_count: usize = dynamic_tools
.iter()
.map(|tool| match tool {
DynamicToolSpec::Function(_) => 1,
DynamicToolSpec::Namespace(namespace) => namespace.tools.len(),
})
.sum();
let mut thread_extension_init = ExtensionDataInit::new();
if !selected_capability_roots.is_empty() {
thread_extension_init.insert(selected_capability_roots);
@@ -1123,7 +1141,7 @@ impl ThreadRequestProcessor {
},
session_source: None,
thread_source,
dynamic_tools: core_dynamic_tools,
dynamic_tools,
metrics_service_name: service_name,
parent_trace: request_trace,
environments,
@@ -1132,7 +1150,7 @@ impl ThreadRequestProcessor {
.instrument(tracing::info_span!(
"app_server.thread_start.create_thread",
otel.name = "app_server.thread_start.create_thread",
thread_start.dynamic_tool_count = core_dynamic_tool_count,
thread_start.dynamic_tool_count = dynamic_tool_count,
))
.await
.map_err(|err| match err {
@@ -142,45 +142,67 @@ mod thread_processor_behavior_tests {
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
fn dynamic_tool(
namespace: Option<&str>,
name: impl Into<String>,
input_schema: Value,
defer_loading: bool,
) -> DynamicToolSpec {
let function = DynamicToolFunctionSpec {
name: name.into(),
description: "test".to_string(),
input_schema,
defer_loading,
};
match namespace {
Some(namespace) => {
DynamicToolSpec::Namespace(codex_app_server_protocol::DynamicToolNamespaceSpec {
name: namespace.to_string(),
description: "test namespace".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(function)],
})
}
None => DynamicToolSpec::Function(function),
}
}
#[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"}),
defer_loading: false,
}];
let tools = vec![dynamic_tool(
/*namespace*/ None,
"my_tool",
json!({"type": "null"}),
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
let tools = vec![dynamic_tool(
/*namespace*/ None,
"my_tool",
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
defer_loading: false,
}];
json!({"properties": {}}),
/*defer_loading*/ false,
)];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[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!({
let tools = vec![dynamic_tool(
/*namespace*/ None,
"my_tool",
json!({
"type": "object",
"properties": {
"query": {"type": ["string", "null"]}
@@ -188,45 +210,57 @@ mod thread_processor_behavior_tests {
"required": ["query"],
"additionalProperties": false
}),
defer_loading: false,
}];
/*defer_loading*/ false,
)];
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!({
dynamic_tool(
Some("codex_app"),
"my_tool",
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!({
/*defer_loading*/ true,
),
dynamic_tool(
Some("other_app"),
"my_tool",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
/*defer_loading*/ true,
),
];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_accepts_responses_compatible_identifiers() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("Codex-App_2".to_string()),
name: "lookup-ticket_2".to_string(),
let tools = vec![dynamic_tool(
Some("Codex-App_2"),
"lookup-ticket_2",
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 function = || DynamicToolFunctionSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
@@ -234,36 +268,17 @@ mod thread_processor_behavior_tests {
"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,
};
let tools = vec![DynamicToolSpec::Namespace(
codex_app_server_protocol::DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "test namespace".to_string(),
tools: vec![
DynamicToolNamespaceTool::Function(function()),
DynamicToolNamespaceTool::Function(function()),
],
},
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}");
@@ -311,53 +326,48 @@ mod thread_processor_behavior_tests {
#[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!({
let tools = vec![dynamic_tool(
Some(""),
"my_tool",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: 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!({
let tools = vec![dynamic_tool(
Some("mcp__server__"),
"my_tool",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: 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 validate_dynamic_tools_rejects_name_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "lookup.ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
let tools = vec![dynamic_tool(
/*namespace*/ None,
"lookup.ticket",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
assert!(
@@ -368,17 +378,16 @@ mod thread_processor_behavior_tests {
#[test]
fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("codex.app".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
let tools = vec![dynamic_tool(
Some("codex.app"),
"lookup_ticket",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
assert!(err.contains("codex.app"), "unexpected error: {err}");
assert!(
@@ -390,54 +399,59 @@ mod thread_processor_behavior_tests {
#[test]
fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() {
let long_name = "a".repeat(129);
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: long_name.clone(),
description: "test".to_string(),
input_schema: json!({
let tools = vec![dynamic_tool(
/*namespace*/ None,
long_name.clone(),
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
/*defer_loading*/ false,
)];
let err = validate_dynamic_tools(&tools).expect_err("name too long");
assert!(err.contains("at most 128"), "unexpected error: {err}");
assert!(err.contains(&long_name), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() {
fn validate_dynamic_tools_rejects_namespace_fields_over_limits() {
let long_namespace = "a".repeat(65);
let tools = vec![ApiDynamicToolSpec {
namespace: Some(long_namespace.clone()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
let mut tools = vec![dynamic_tool(
Some(&long_namespace),
"lookup_ticket",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
assert!(err.contains("at most 64"), "unexpected error: {err}");
assert!(err.contains(&long_namespace), "unexpected error: {err}");
let DynamicToolSpec::Namespace(namespace) = &mut tools[0] else {
unreachable!("expected namespace")
};
namespace.name = "tickets".to_string();
namespace.description = "a".repeat(1025);
let err = validate_dynamic_tools(&tools).expect_err("namespace description too long");
assert!(err.contains("at most 1024"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_reserved_responses_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("functions".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
let tools = vec![dynamic_tool(
Some("functions"),
"lookup_ticket",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
/*defer_loading*/ true,
)];
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
assert!(err.contains("functions"), "unexpected error: {err}");
assert!(err.contains("Responses API"), "unexpected error: {err}");