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
parent b3f6f70b68
commit a292faae5a
20 changed files with 654 additions and 277 deletions
+45 -30
View File
@@ -12,6 +12,9 @@ use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::PermissionProfile;
@@ -3286,6 +3289,7 @@ text(JSON.stringify(tool));
serde_json::json!({
"name": "mcp__rmcp__echo",
"description": concat!(
"Use these tools to exercise the rmcp test server.\n\n",
"Echo back the provided message and include environment data.\n\n",
"exec tool declaration:\n",
"```ts\n",
@@ -3312,20 +3316,25 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: true,
}],
vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Codex app tools.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: true,
},
)],
})],
)
.await?;
let mut test = base_test;
@@ -3333,8 +3342,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
test.session_configured = new_thread.session_configured;
let code = r#"
const tool = ALL_TOOLS.find(({ name }) => name === "codex_app_hidden_dynamic_tool");
const out = await tools.codex_app_hidden_dynamic_tool({ city: "Paris" });
const tool = ALL_TOOLS.find(({ name }) => name === "codex_app__hidden_dynamic_tool");
const out = await tools.codex_app__hidden_dynamic_tool({ city: "Paris" });
text(
JSON.stringify({
name: tool?.name ?? null,
@@ -3441,7 +3450,7 @@ text(
)?;
assert_eq!(
parsed.get("name"),
Some(&Value::String("codex_app_hidden_dynamic_tool".to_string()))
Some(&Value::String("codex_app__hidden_dynamic_tool".to_string()))
);
assert_eq!(
parsed.get("out"),
@@ -3452,9 +3461,10 @@ text(
.get("description")
.and_then(Value::as_str)
.is_some_and(|description| {
description.contains("A hidden dynamic tool.")
description.contains("Codex app tools.")
&& description.contains("A hidden dynamic tool.")
&& description.contains("declare const tools:")
&& description.contains("codex_app_hidden_dynamic_tool(args:")
&& description.contains("codex_app__hidden_dynamic_tool(args:")
})
);
@@ -3475,17 +3485,22 @@ async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> {
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
vec![DynamicToolSpec {
namespace: Some("excluded".to_string()),
name: "lookup".to_string(),
description: "An excluded dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
}],
vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "excluded".to_string(),
description: "Excluded tools.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: "lookup".to_string(),
description: "An excluded dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
},
)],
})],
)
.await?;
let mut test = base_test;
+33 -23
View File
@@ -8,6 +8,9 @@ use codex_core::compact::SUMMARY_PREFIX;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::config_types::ServiceTier;
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::items::TurnItem;
use codex_protocol::models::ContentItem;
@@ -1147,22 +1150,24 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
"properties": {},
"additionalProperties": false,
});
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema,
defer_loading: 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: input_schema.clone(),
defer_loading: true,
}),
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema,
defer_loading: false,
}),
],
})];
let new_thread = test
.thread_manager
.start_thread_with_tools(test.config.clone(), dynamic_tools)
@@ -1784,13 +1789,18 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul
"required": ["mode"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: tool_name.to_string(),
description: tool_description,
input_schema,
defer_loading: true,
};
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Codex app tools.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: tool_name.to_string(),
description: tool_description,
input_schema,
defer_loading: true,
},
)],
});
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
+36 -22
View File
@@ -7,6 +7,9 @@ use codex_config::types::McpServerTransportConfig;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -916,13 +919,18 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
"required": ["mode"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
};
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Automation tools.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
},
)],
});
let mut builder = test_codex().with_config(configure_search_capable_model);
let base_test = builder.build(&server).await?;
@@ -998,7 +1006,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
vec![json!({
"type": "namespace",
"name": "codex_app",
"description": "Tools in the codex_app namespace.",
"description": "Automation tools.",
"tools": [{
"type": "function",
"name": tool_name,
@@ -1535,21 +1543,27 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch
)
.await;
let dynamic_tool = DynamicToolSpec {
namespace: Some("orbit_ops".to_string()),
name: "quasar_ping_beacon".to_string(),
description: "Trigger the saffron metronome workflow for reminder follow-ups.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"chrono_spec": { "type": "string" },
"targetThreadId": { "type": "string" },
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "orbit_ops".to_string(),
description: "Orbital reminder operations.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: "quasar_ping_beacon".to_string(),
description: "Trigger the saffron metronome workflow for reminder follow-ups."
.to_string(),
input_schema: json!({
"type": "object",
"properties": {
"chrono_spec": { "type": "string" },
"targetThreadId": { "type": "string" },
},
"required": ["chrono_spec"],
"additionalProperties": false,
}),
defer_loading: true,
},
"required": ["chrono_spec"],
"additionalProperties": false,
}),
defer_loading: true,
};
)],
});
let mut builder = test_codex().with_config(configure_search_capable_model);
let base_test = builder.build(&server).await?;
+160 -20
View File
@@ -7,6 +7,9 @@ use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::ThreadId;
use codex_protocol::config_types::WebSearchMode;
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::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -117,18 +120,28 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
)
.await;
let dynamic_tool = DynamicToolSpec {
namespace: None,
name: "resume_lookup".to_string(),
description: "Look up a value after resume.".to_string(),
input_schema: json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"],
"additionalProperties": false,
}),
defer_loading: false,
};
let namespace = "resume_tools";
let namespace_description = "Tools available after resume.";
let tool_name = "resume_lookup";
let tool_description = "Look up a value after resume.";
let input_schema = json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: namespace.to_string(),
description: namespace_description.to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
defer_loading: false,
},
)],
});
let mut builder = test_codex().with_config(|config| {
config
.features
@@ -138,7 +151,7 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
let base_test = builder.build(&server).await?;
let started = base_test
.thread_manager
.start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool.clone()])
.start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool])
.await?;
let rollout_path = started
.session_configured
@@ -182,17 +195,144 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
.get("tools")
.and_then(serde_json::Value::as_array)
.expect("resumed request tools");
let restored_tool = tools
let restored_namespace = tools
.iter()
.find(|tool| tool.get("name") == Some(&json!(dynamic_tool.name.as_str())))
.expect("dynamic tool should be restored from rollout metadata");
.find(|tool| tool.get("name") == Some(&json!(namespace)))
.expect("dynamic tool namespace should be restored from rollout metadata");
assert_eq!(
restored_tool.get("description"),
Some(&json!(dynamic_tool.description.as_str()))
restored_namespace,
&json!({
"type": "namespace",
"name": namespace,
"description": namespace_description,
"tools": [{
"type": "function",
"name": tool_name,
"description": tool_description,
"strict": false,
"parameters": input_schema,
}],
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_restores_legacy_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> {
let server = start_mock_server().await;
let mock = mount_sse_sequence(
&server,
vec![
responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let namespace = "resume_tools";
let tool_name = "resume_lookup";
let tool_description = "Look up a value after resume.";
let input_schema = json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"],
"additionalProperties": false,
});
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow feature update");
});
let base_test = builder.build(&server).await?;
let started = base_test
.thread_manager
.start_thread_with_tools(base_test.config.clone(), Vec::new())
.await?;
let rollout_path = started
.session_configured
.rollout_path
.clone()
.expect("rollout path");
started
.thread
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "persist this thread".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
wait_for_event(&started.thread, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
started.thread.submit(Op::Shutdown).await?;
wait_for_event(&started.thread, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
let mut rollout_lines = fs::read_to_string(&rollout_path)?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<serde_json::Result<Vec<_>>>()?;
rollout_lines.first_mut().expect("session metadata line")["payload"]["dynamic_tools"] = json!([{
"namespace": namespace,
"name": tool_name,
"description": tool_description,
"inputSchema": input_schema,
"exposeToContext": true,
}]);
let rollout = rollout_lines
.iter()
.map(serde_json::to_string)
.collect::<serde_json::Result<Vec<_>>>()?
.join("\n");
fs::write(&rollout_path, format!("{rollout}\n"))?;
let mut resume_builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow feature update");
});
let resumed = resume_builder
.resume(&server, base_test.home.clone(), rollout_path)
.await?;
resumed.submit_turn("use the restored tool").await?;
let requests = mock.requests();
assert_eq!(requests.len(), 2);
let resumed_body = requests[1].body_json();
let tools = resumed_body
.get("tools")
.and_then(serde_json::Value::as_array)
.expect("resumed request tools");
let restored_namespace = tools
.iter()
.find(|tool| tool.get("name") == Some(&json!(namespace)))
.expect("dynamic tool namespace should be restored from rollout metadata");
assert_eq!(
restored_tool.get("parameters"),
Some(&dynamic_tool.input_schema)
restored_namespace,
&json!({
"type": "namespace",
"name": namespace,
"description": "Tools in the resume_tools namespace.",
"tools": [{
"type": "function",
"name": tool_name,
"description": tool_description,
"strict": false,
"parameters": input_schema,
}],
})
);
Ok(())