Represent dynamic tools with explicit namespaces internally (#27365)

Follow-up to #27356.

## Stack note

This PR changes Codex's internal dynamic-tool shape while leaving
`thread/start` unchanged. App-server therefore converts the existing
per-tool input into explicit functions and namespaces before passing it
to core.

[#27371](https://github.com/openai/codex/pull/27371) updates
`thread/start` to use the same explicit shape and removes this temporary
conversion.

## Why

Dynamic tools repeat namespace metadata on every function. Core should
keep one explicit namespace with its member tools so descriptions and
membership stay consistent across sessions and runtime planning.

## What changed

- Represent dynamic tools as top-level functions or explicit namespaces
in protocol and session state.
- Read old flat rollout metadata and write the canonical hierarchy.
- Flatten namespace members only when registering callable tools.
- Keep `thread/start.dynamicTools` flat for now and normalize it at the
app-server boundary.

New builds can read old rollout metadata. Older builds cannot read newly
written hierarchical metadata.

## Test plan

- `just test -p codex-app-server
thread_start_normalizes_legacy_dynamic_tools_into_model_request`
- `just test -p codex-protocol
session_meta_normalizes_legacy_dynamic_tools`
- `just test -p codex-core
resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled`
- `just test -p codex-core
tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call`
- `just test -p codex-core code_mode_can_call_hidden_dynamic_tools`
- `just test -p codex-tools`
This commit is contained in:
sayan-oai
2026-06-15 08:06:14 -07:00
committed by GitHub
Unverified
parent b3f6f70b68
commit a292faae5a
20 changed files with 656 additions and 279 deletions
+28 -6
View File
@@ -11,8 +11,9 @@ use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolExposure;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::EventMsg;
@@ -36,13 +37,34 @@ pub struct DynamicToolHandler {
}
impl DynamicToolHandler {
pub fn new(tool: &DynamicToolSpec) -> Option<Self> {
let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
pub fn new(tool: &DynamicToolFunctionSpec) -> Option<Self> {
Self::from_parts(tool, /*namespace*/ None)
}
pub fn new_in_namespace(
namespace: &DynamicToolNamespaceSpec,
tool: &DynamicToolFunctionSpec,
) -> Option<Self> {
Self::from_parts(tool, Some(namespace))
}
fn from_parts(
tool: &DynamicToolFunctionSpec,
namespace: Option<&DynamicToolNamespaceSpec>,
) -> Option<Self> {
let tool_name = ToolName::new(
namespace.map(|namespace| namespace.name.clone()),
tool.name.clone(),
);
let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
let spec = match tool.namespace.as_ref() {
let spec = match namespace {
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: default_namespace_description(namespace),
name: namespace.name.clone(),
description: if namespace.description.trim().is_empty() {
default_namespace_description(&namespace.name)
} else {
namespace.description.clone()
},
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => ToolSpec::Function(output_tool),
@@ -144,7 +144,8 @@ mod tests {
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::McpHandler;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
@@ -154,8 +155,12 @@ mod tests {
#[test]
fn mixed_search_results_coalesce_mcp_namespaces() {
let dynamic_tools = [DynamicToolSpec {
namespace: Some("codex_app".to_string()),
let dynamic_namespace = DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Tools in the codex_app namespace.".to_string(),
tools: Vec::new(),
};
let dynamic_tools = [DynamicToolFunctionSpec {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
@@ -182,7 +187,7 @@ mod tests {
})
.collect::<Vec<_>>();
search_infos.extend(dynamic_tools.iter().map(|tool| {
DynamicToolHandler::new(tool)
DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool)
.expect("dynamic tool should convert")
.search_info()
.expect("dynamic handler should return search info")
+27 -22
View File
@@ -10,6 +10,9 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall as ExtensionToolCall;
use codex_extension_api::ToolExecutor;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -249,30 +252,32 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
let (_, turn) = make_session_and_context().await;
let hidden_tool = "hidden_dynamic_tool";
let visible_tool = "visible_dynamic_tool";
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Codex app tools.".to_string(),
tools: vec![
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: true,
}),
defer_loading: true,
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
}),
defer_loading: false,
},
];
],
})];
let router = ToolRouter::from_turn_context(
&turn,
+29 -10
View File
@@ -59,6 +59,7 @@ use codex_features::Feature;
use codex_login::AuthManager;
use codex_mcp::ToolInfo;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
@@ -813,16 +814,34 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}
fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
for tool in context.dynamic_tools {
let Some(handler) = DynamicToolHandler::new(tool) else {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool",
tool.name
);
continue;
};
planned_tools.add(handler);
for spec in context.dynamic_tools {
match spec {
DynamicToolSpec::Function(tool) => {
let Some(handler) = DynamicToolHandler::new(tool) else {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool",
tool.name
);
continue;
};
planned_tools.add(handler);
}
DynamicToolSpec::Namespace(namespace) => {
for tool in &namespace.tools {
let DynamicToolNamespaceTool::Function(tool) = tool;
let Some(handler) = DynamicToolHandler::new_in_namespace(namespace, tool)
else {
tracing::error!(
"Failed to convert dynamic tool {:?}.{:?} to OpenAI tool",
namespace.name,
tool.name
);
continue;
};
planned_tools.add(handler);
}
}
}
}
}
+13 -2
View File
@@ -384,8 +384,7 @@ fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
}
fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec {
DynamicToolSpec {
namespace: namespace.map(str::to_string),
let function = codex_protocol::dynamic_tools::DynamicToolFunctionSpec {
name: name.to_string(),
description: format!("{name} dynamic tool"),
input_schema: json!({
@@ -394,6 +393,18 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn
"additionalProperties": false,
}),
defer_loading,
};
match namespace {
Some(namespace) => {
DynamicToolSpec::Namespace(codex_protocol::dynamic_tools::DynamicToolNamespaceSpec {
name: namespace.to_string(),
description: format!("{namespace} dynamic tools"),
tools: vec![
codex_protocol::dynamic_tools::DynamicToolNamespaceTool::Function(function),
],
})
}
None => DynamicToolSpec::Function(function),
}
}