mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
core: expose deferred dynamic tools without tool_search
This commit is contained in:
@@ -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.
|
||||
|
||||
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
|
||||
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:")
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
Reference in New Issue
Block a user