Compare commits

...

2 Commits

Author SHA1 Message Date
Sayan Sisodiya
f21c73be9d docs: clarify namespace limit on dynamic tool fallback 2026-05-26 17:39:19 -07:00
Sayan Sisodiya
0113ae1b57 core: expose deferred dynamic tools without tool_search 2026-05-26 17:38:08 -07:00
7 changed files with 133 additions and 17 deletions

View File

@@ -1408,7 +1408,7 @@ Dynamic tool identifiers follow the same constraints as Responses function tools
- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters.
- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. If `tool_search` is unavailable due to model/provider restrictions, deferred dynamic tools are exposed directly; normal provider namespace support still applies.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:

View File

@@ -246,7 +246,8 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()>
#[tokio::test]
async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
let (_, turn) = make_session_and_context().await;
let (_, mut turn) = make_session_and_context().await;
turn.model_info.supports_search_tool = true;
let hidden_tool = "hidden_dynamic_tool";
let visible_tool = "visible_dynamic_tool";
let dynamic_tools = vec![

View File

@@ -278,6 +278,10 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.provider.capabilities().namespace_tools
}
fn tool_search_available(turn_context: &TurnContext) -> bool {
search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)
}
fn code_mode_enabled(turn_context: &TurnContext) -> bool {
turn_context.features.get().enabled(Feature::CodeMode)
}
@@ -681,12 +685,11 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
} else {
let agent_type_description =
agent_type_description(turn_context, context.default_agent_type_description);
let exposure =
if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) {
ToolExposure::Deferred
} else {
ToolExposure::Direct
};
let exposure = if tool_search_available(turn_context) {
ToolExposure::Deferred
} else {
ToolExposure::Direct
};
planned_tools.add_with_exposure(
SpawnAgentHandler::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
@@ -746,8 +749,18 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}
fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
let can_use_tool_search = tool_search_available(context.turn_context);
for tool in context.dynamic_tools {
let Some(handler) = DynamicToolHandler::new(tool) else {
let direct_tool = (tool.defer_loading && !can_use_tool_search).then(|| {
// A deferred tool must stay visible when tool_search cannot expose it.
DynamicToolSpec {
defer_loading: false,
..tool.clone()
}
});
let effective_tool = direct_tool.as_ref().unwrap_or(tool);
let Some(handler) = DynamicToolHandler::new(effective_tool) else {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool",
tool.name
@@ -774,7 +787,7 @@ fn append_tool_search_executor(
planned_tools: &mut PlannedTools,
) {
let turn_context = context.turn_context;
if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) {
if !tool_search_available(turn_context) {
return;
}
@@ -827,8 +840,7 @@ fn append_extension_tool_executors(
reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME));
reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME));
}
if search_tool_enabled(turn_context)
&& namespace_tools_enabled(turn_context)
if tool_search_available(turn_context)
&& planned_tools
.runtimes()
.iter()

View File

@@ -553,6 +553,35 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
]);
}
#[tokio::test]
async fn deferred_plain_dynamic_tool_is_direct_without_namespace_support() {
let plan = probe_with(
|turn| {
turn.model_info.supports_search_tool = true;
use_bedrock_provider(turn);
},
ToolPlanInputs {
dynamic_tools: vec![dynamic_tool(
None,
"plain_lookup",
/*defer_loading*/ true,
)],
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_lacks(&["tool_search"]);
assert_eq!(plan.exposure("plain_lookup"), ToolExposure::Direct);
let ToolSpec::Function(tool) = plan.visible_spec("plain_lookup") else {
panic!("expected visible plain dynamic tool");
};
assert_eq!(
(tool.name.as_str(), tool.defer_loading),
("plain_lookup", None)
);
}
#[tokio::test]
async fn invalid_mcp_tools_are_not_registered() {
let plan = probe_with(

View File

@@ -18,6 +18,7 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::configure_search_capable_model;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
@@ -2919,6 +2920,7 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
let server = responses::start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
configure_search_capable_model(config);
let _ = config.features.enable(Feature::CodeMode);
});
let base_test = builder.build(&server).await?;
@@ -2948,8 +2950,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,
@@ -3054,7 +3056,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"),
@@ -3067,7 +3069,7 @@ text(
.is_some_and(|description| {
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:")
})
);

View File

@@ -24,6 +24,7 @@ use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::configure_search_capable_model;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
@@ -1036,7 +1037,9 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(configure_search_capable_model);
let mut test = builder.build(&server).await?;
let hidden_tool = "hidden_dynamic_tool";
let visible_tool = "visible_dynamic_tool";

View File

@@ -6,6 +6,7 @@ use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
@@ -962,6 +963,74 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deferred_dynamic_tool_is_direct_without_tool_search_support() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_update".to_string(),
description: "Manage automations.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: true,
};
let mut builder = test_codex().with_config(|config| {
let mut model_catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let model = model_catalog
.models
.iter_mut()
.find(|model| model.slug == "gpt-5.4")
.expect("gpt-5.4 exists in bundled models.json");
model.supports_search_tool = false;
config.model = Some("gpt-5.4".to_string());
config.model_catalog = Some(model_catalog);
});
let base_test = builder.build(&server).await?;
let new_thread = base_test
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
vec![dynamic_tool],
/*persist_extended_history*/ false,
)
.await?;
let mut test = base_test;
test.codex = new_thread.thread;
test.session_configured = new_thread.session_configured;
test.submit_turn("Use the automation tool").await?;
let body = mock.single_request().body_json();
assert!(
!tool_names(&body)
.iter()
.any(|name| name == TOOL_SEARCH_TOOL_NAME),
"tool_search should be absent for a model without tool-search support"
);
let direct_tool = namespace_child_tool(&body, "codex_app", "automation_update")
.expect("deferred dynamic tool should be exposed directly");
assert_eq!(direct_tool.get("defer_loading"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
skip_if_no_network!(Ok(()));