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-18 04:07:54 +00:00
committed by GitHub
parent ac3fe64100
commit 78a9e169bb
9 changed files with 183 additions and 1 deletions
+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(