Expose selecte namespaces as direct model tools (#28825)

## Why

Som tools, such as history and notes, must remain top-level when MCP
deferral is enabled while staying unavailable through code-mode `exec`.

## What changed

- Added `features.code_mode.direct_only_tool_namespaces`.
- Classified matching MCP tools as `DirectModelOnly`.
- Kept those tools top-level in `code_mode_only`.
- Excluded them from `tool_search` deferral and the nested `exec`
surface.
- Updated the generated config schema.

## Validation

- `code_mode_only_exposes_direct_model_only_mcp_namespaces`
- `load_config_resolves_code_mode_config`
This commit is contained in:
Won Park
2026-06-17 21:07:54 -07:00
committed by GitHub
Unverified
parent ac3fe64100
commit 78a9e169bb
9 changed files with 183 additions and 1 deletions
+7
View File
@@ -362,6 +362,13 @@
"CodeModeConfigToml": {
"additionalProperties": false,
"properties": {
"direct_only_tool_namespaces": {
"description": "Exact tool namespaces to expose only as direct model tools. These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted from the nested code-mode tool surface.",
"items": {
"type": "string"
},
"type": "array"
},
"enabled": {
"type": "boolean"
},
+5
View File
@@ -438,6 +438,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> {
[features.code_mode]
enabled = true
excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"]
direct_only_tool_namespaces = ["mcp__history", "mcp__notes"]
"#,
)
.expect("TOML deserialization should succeed");
@@ -452,6 +453,10 @@ excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"]
config.code_mode.excluded_tool_namespaces,
vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()]
);
assert_eq!(
config.code_mode.direct_only_tool_namespaces,
vec!["mcp__history".to_string(), "mcp__notes".to_string()]
);
assert!(config.features.enabled(Feature::CodeMode));
Ok(())
}
+5
View File
@@ -1061,6 +1061,7 @@ pub struct Config {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CodeModeConfig {
pub excluded_tool_namespaces: Vec<String>,
pub direct_only_tool_namespaces: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -2404,6 +2405,10 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
.and_then(|config| config.excluded_tool_namespaces.as_ref())
.cloned()
.unwrap_or_default(),
direct_only_tool_namespaces: base
.and_then(|config| config.direct_only_tool_namespaces.as_ref())
.cloned()
.unwrap_or_default(),
}
}
+3 -1
View File
@@ -56,7 +56,9 @@ impl DynamicToolHandler {
namespace.map(|namespace| namespace.name.clone()),
tool.name.clone(),
);
let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
// Exposure controls deferral; tool search restores this marker for deferred results.
output_tool.defer_loading = None;
let spec = match namespace {
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.name.clone(),
+30
View File
@@ -192,11 +192,41 @@ fn build_tool_specs_and_registry(
};
let mut planned_tools = PlannedTools::default();
add_tool_sources(&context, &mut planned_tools);
apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools);
append_tool_search_executor(&context, &mut planned_tools);
prepend_code_mode_executors(&context, &mut planned_tools);
build_model_visible_specs_and_registry(turn_context, planned_tools)
}
fn apply_direct_model_only_namespace_overrides(
turn_context: &TurnContext,
planned_tools: &mut PlannedTools,
) {
for runtime in &mut planned_tools.runtimes {
let configured = runtime
.tool_name()
.namespace
.as_ref()
.is_some_and(|namespace| {
turn_context
.config
.code_mode
.direct_only_tool_namespaces
.contains(namespace)
});
match runtime.exposure() {
ToolExposure::Direct | ToolExposure::Deferred if configured => {
*runtime =
override_tool_exposure(Arc::clone(runtime), ToolExposure::DirectModelOnly);
}
ToolExposure::Direct
| ToolExposure::Deferred
| ToolExposure::DirectModelOnly
| ToolExposure::Hidden => {}
}
}
}
#[instrument(level = "trace", skip_all)]
fn build_model_visible_specs_and_registry(
turn_context: &TurnContext,
@@ -1011,6 +1011,48 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {
);
}
#[tokio::test]
async fn code_mode_only_exposes_configured_dynamic_namespace_directly() {
let plan = probe_with(
|turn| {
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
turn.model_info.supports_search_tool = true;
update_config(turn, |config| {
config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()];
});
},
ToolPlanInputs {
dynamic_tools: vec![dynamic_tool(
Some("direct_only"),
"lookup",
/*defer_loading*/ true,
)],
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_contains(&[
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::WAIT_TOOL_NAME,
"direct_only",
]);
plan.assert_visible_lacks(&["tool_search"]);
assert_eq!(
plan.exposure(&ToolName::namespaced("direct_only", "lookup").to_string()),
ToolExposure::DirectModelOnly
);
let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else {
panic!("expected direct-only namespace spec");
};
let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0];
assert_eq!(tool.defer_loading, None);
let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {
panic!("expected code mode exec tool");
};
assert!(!exec.description.contains("direct_only_lookup(args:"));
}
#[tokio::test]
async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() {
let plan = probe_with(
@@ -0,0 +1,85 @@
use anyhow::Result;
use codex_features::Feature;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::namespace_child_tool;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use serde_json::Value;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount_searchable(&server).await?;
let response = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(move |config| {
config
.features
.enable(Feature::CodeModeOnly)
.expect("test config should allow feature update");
config
.features
.enable(Feature::ToolSearchAlwaysDeferMcpTools)
.expect("test config should allow feature update");
config.code_mode.direct_only_tool_namespaces =
vec![SEARCH_CALENDAR_NAMESPACE.to_string()];
});
let test = builder.build(&server).await?;
test.submit_turn("inspect directly exposed MCP tools")
.await?;
let body = response.single_request().body_json();
let tools = body
.get("tools")
.and_then(Value::as_array)
.expect("request should contain tools");
assert!(
namespace_child_tool(
&body,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
)
.is_some(),
"configured MCP namespace should remain top-level: {body}"
);
assert!(
!tools.iter().any(|tool| {
tool.get("name")
.or_else(|| tool.get("type"))
.and_then(Value::as_str)
== Some("tool_search")
}),
"configured MCP namespace should not be deferred: {body}"
);
let exec_description = tools.iter().find_map(|tool| {
(tool.get("name").and_then(Value::as_str) == Some("exec"))
.then(|| tool.get("description").and_then(Value::as_str))
.flatten()
});
assert!(
exec_description.is_some_and(|description| {
!description.contains("mcp__codex_apps__calendar_create_event(args:")
}),
"direct-model-only MCP namespace should not be available through exec: {body}"
);
Ok(())
}
+1
View File
@@ -65,6 +65,7 @@ mod image_rollout;
mod items;
mod json_result;
mod live_cli;
mod mcp_tool_exposure;
mod mcp_turn_metadata;
mod model_overrides;
mod model_runtime_selectors;
+5
View File
@@ -12,6 +12,11 @@ pub struct CodeModeConfigToml {
/// Exact tool namespaces to omit from the code-mode nested tool surface.
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_tool_namespaces: Option<Vec<String>>,
/// Exact tool namespaces to expose only as direct model tools.
/// These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted
/// from the nested code-mode tool surface.
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_only_tool_namespaces: Option<Vec<String>>,
}
impl FeatureConfig for CodeModeConfigToml {