Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/core/src/config/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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(())
}
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
}
}

Expand Down
4 changes: 3 additions & 1 deletion codex-rs/core/src/tools/handlers/dynamic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dynamic input currently drives both ToolExposure and ResponsesApiTool.defer_loading. Once the planner promotes a configured deferred runtime to DirectModelOnly, leaving this field set would serialize the now-top-level tool with defer_loading: true even though it has also been removed from tool_search. Clearing it at construction keeps the stored spec direct-shaped; ToolSearchInfo::from_tool_spec adds the marker back only when the tool is actually returned as a deferred search result. A short comment here would make that invariant clearer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks codex lol

maybe comment like // ToolExposure is canonical; tool search adds the wire-level marker for deferred results.

let spec = match namespace {
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.name.clone(),
Expand Down
30 changes: 30 additions & 0 deletions codex-rs/core/src/tools/spec_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
}
}
Comment on lines +205 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i know we're against one-time helpers but i think in this case its warranted; this is logic very specific to this custom config setup and imo it shouldnt pollute build_tool_specs_and_registry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This is a good exception to the one-use-helper rule because it is self-contained config policy, while build_tool_specs_and_registry should read as orchestration. I would extract apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools) so the pipeline remains obvious: add sources, apply exposure policy, build tool_search, then build code-mode executors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

}

#[instrument(level = "trace", skip_all)]
fn build_model_visible_specs_and_registry(
turn_context: &TurnContext,
Expand Down
42 changes: 42 additions & 0 deletions codex-rs/core/src/tools/spec_plan_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
85 changes: 85 additions & 0 deletions codex-rs/core/tests/suite/mcp_tool_exposure.rs
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions codex-rs/core/tests/suite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/features/src/feature_configs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading