From ec9620c231396895194329c410f3ec360b4cadef Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 28 Aug 2026 02:02:47 +0000 Subject: [PATCH] Add configurable gating for the sleep tool (#41243) ## What changed - Add a stable `sleep_tool` feature that can enable or disable registration of the built-in sleep tool independently of the clock tool. - Support structured `features.sleep_tool` configuration with `model_driven` and `always_on` modes. The default `model_driven` mode preserves the existing model and `current_time_reminder` behavior, while `always_on` registers sleep whenever the feature is enabled. - Preserve nested sleep-tool configuration across CLI overrides, config merges, and feature toggles, and expose the settings in the generated config schema. ## Testing - Cover feature-map overrides, both selection modes, legacy clock settings, config merging and editing, and invalid mode rejection. GitOrigin-RevId: 18eadc582f8a4445958c29d063e5be495276703e --- codex-rs/config/src/merge.rs | 5 +- codex-rs/config/src/merge_tests.rs | 32 +++++ codex-rs/config/src/schema.rs | 9 ++ codex-rs/core/config.schema.json | 47 ++++++++ codex-rs/core/src/config/edit_tests.rs | 27 +++++ codex-rs/core/src/config/mod.rs | 13 ++ codex-rs/core/src/tools/spec_plan.rs | 35 ++++-- .../core/tests/suite/current_time_reminder.rs | 112 ++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 26 ++++ codex-rs/features/src/lib.rs | 15 +++ codex-rs/features/src/tests.rs | 5 + codex-rs/thread-manager-sample/src/main.rs | 1 + 12 files changed, 313 insertions(+), 14 deletions(-) diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index 187d8bcdd0..cc1b08d668 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -67,7 +67,10 @@ pub fn is_structured_feature_path>(path: &[S]) -> bool { }; features.as_ref() == "features" - && matches!(feature.as_ref(), "multi_agent_v2" | "network_proxy") + && matches!( + feature.as_ref(), + "multi_agent_v2" | "network_proxy" | "sleep_tool" + ) } fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec) { diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs index d1986e4db5..270436927b 100644 --- a/codex-rs/config/src/merge_tests.rs +++ b/codex-rs/config/src/merge_tests.rs @@ -255,6 +255,38 @@ fn multi_agent_v2_cli_overrides_preserve_boolean_and_nested_configuration() { } } +#[test] +fn sleep_tool_overrides_preserve_disabled_state_and_mode() { + for feature_path in ["features", "profiles.work.features"] { + let disabled = ( + format!("{feature_path}.sleep_tool"), + TomlValue::Boolean(false), + ); + let mode = ( + format!("{feature_path}.sleep_tool.mode"), + TomlValue::String("always_on".to_string()), + ); + let expected = parse_toml(&format!( + "[{feature_path}.sleep_tool]\nenabled = false\nmode = \"always_on\"\n", + )); + for overrides in [vec![disabled.clone(), mode.clone()], vec![mode, disabled]] { + assert_eq!(crate::build_cli_overrides_layer(&overrides), expected); + } + + let boolean_layer = parse_toml(&format!("[{feature_path}]\nsleep_tool = false\n")); + let table_layer = parse_toml(&format!( + "[{feature_path}.sleep_tool]\nmode = \"always_on\"\n", + )); + for (mut base, overlay) in [ + (boolean_layer.clone(), table_layer.clone()), + (table_layer, boolean_layer), + ] { + merge_toml_values(&mut base, &overlay); + assert_eq!(base, expected); + } + } +} + #[test] fn network_proxy_feature_overrides_preserve_credential_broker_configuration() { let enabled = ( diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index bcebcbb759..3198353f38 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -122,6 +122,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::SleepTool { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::AppsMcpPathOverride { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index abed992767..052093ab86 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -907,6 +907,9 @@ "skip_host_skill_discovery": { "type": "boolean" }, + "sleep_tool": { + "$ref": "#/definitions/FeatureToml_for_SleepToolConfigToml" + }, "sqlite": { "type": "boolean" }, @@ -1262,6 +1265,16 @@ } ] }, + "FeatureToml_for_SleepToolConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/SleepToolConfigToml" + } + ] + }, "FeatureToml_for_TokenBudgetConfigToml": { "anyOf": [ { @@ -3499,6 +3512,37 @@ }, "type": "object" }, + "SleepToolConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/SleepToolMode" + } + }, + "type": "object" + }, + "SleepToolMode": { + "description": "How the sleep tool is selected when its feature gate is enabled.", + "oneOf": [ + { + "description": "Preserve the existing model and legacy clock configuration defaults.", + "enum": [ + "model_driven" + ], + "type": "string" + }, + { + "description": "Register sleep regardless of the model or legacy clock configuration.", + "enum": [ + "always_on" + ], + "type": "string" + } + ] + }, "ThreadStoreToml": { "oneOf": [ { @@ -6063,6 +6107,9 @@ "skip_host_skill_discovery": { "type": "boolean" }, + "sleep_tool": { + "$ref": "#/definitions/FeatureToml_for_SleepToolConfigToml" + }, "sqlite": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 1afcafccd0..7271ce78b5 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -136,6 +136,33 @@ fn multi_agent_v2_feature_toggle_preserves_nested_configuration() { ); } +#[test] +fn sleep_tool_feature_toggle_preserves_mode() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let config_path = codex_home.join(CONFIG_TOML_FILE); + std::fs::write( + &config_path, + "[features.sleep_tool]\nmode = \"always_on\"\n", + ) + .expect("write config"); + + for enabled in [false, true] { + ConfigEditsBuilder::new(codex_home) + .set_feature_enabled("sleep_tool", enabled) + .apply_blocking() + .expect("toggle feature"); + let actual: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str(&format!( + "[features.sleep_tool]\nenabled = {enabled}\nmode = \"always_on\"\n", + )) + .expect("parse expected config"); + assert_eq!(actual, expected); + } +} + #[test] fn network_proxy_feature_toggle_preserves_credential_broker_configuration() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 96da2f76f8..9fb4f141ed 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -73,6 +73,7 @@ use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; +use codex_features::SleepToolMode; use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_http_client::HttpClientFactory; @@ -1045,6 +1046,8 @@ pub struct Config { pub rollout_budget: Option, /// Current-time reminder and clock tool configuration, when enabled. pub current_time_reminder: Option, + /// How the sleep tool is selected when its feature gate is enabled. + pub sleep_tool_mode: SleepToolMode, /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, @@ -3684,6 +3687,15 @@ impl Config { let token_budget = resolve_token_budget_config(&cfg, &features)?; let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?; + let sleep_tool_mode = cfg + .features + .as_ref() + .and_then(|features| features.sleep_tool.as_ref()) + .and_then(|feature| match feature { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => config.mode, + }) + .unwrap_or_default(); let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -4282,6 +4294,7 @@ impl Config { token_budget, rollout_budget, current_time_reminder, + sleep_tool_mode, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 5d5d0c7bc9..24e15e8be5 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -62,6 +62,7 @@ use crate::tools::router::ToolRouter; use crate::tools::tool_namespaces_info::collect_tool_namespaces_info; use codex_extension_api::ExtensionData; use codex_features::Feature; +use codex_features::SleepToolMode; use codex_login::AuthManager; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::account::PlanType; @@ -1153,23 +1154,31 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool registry.add(GetContextRemainingHandler); } - if features.enabled(Feature::CurrentTimeReminder) { - registry.add(CurrentTimeHandler); - if turn_context - .config - .current_time_reminder - .as_ref() - .is_some_and(|config| config.sleep_tool) - { - registry.add(SleepHandler); - } - } else if context + let current_time_reminder_enabled = features.enabled(Feature::CurrentTimeReminder); + let model_has_clock = context .model_info .experimental_supported_tools .iter() - .any(|tool| tool == "clock") - { + .any(|tool| tool == "clock"); + if current_time_reminder_enabled || model_has_clock { registry.add(CurrentTimeHandler); + } + if features.enabled(Feature::SleepTool) + && match turn_context.config.sleep_tool_mode { + SleepToolMode::AlwaysOn => true, + SleepToolMode::ModelDriven => { + if current_time_reminder_enabled { + turn_context + .config + .current_time_reminder + .as_ref() + .is_some_and(|config| config.sleep_tool) + } else { + model_has_clock + } + } + } + { registry.add(SleepHandler); } diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index 400adc0500..e6ada23200 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::AtomicU64; @@ -428,6 +429,117 @@ async fn system_time_source_adds_current_time_reminder(clock_setup: ClockSetup) Ok(()) } +#[test_case(ReasoningEffort::High, true; "model_enabled")] +#[test_case(ReasoningEffort::High, false; "model_disabled")] +#[test_case(ReasoningEffort::Persistent, true; "persistent_enabled")] +#[test_case(ReasoningEffort::Persistent, false; "persistent_disabled")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_feature_map_can_disable_sleep_tool( + reasoning_effort: ReasoningEffort, + sleep_tool_enabled: bool, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let reminders_enabled = reasoning_effort == ReasoningEffort::Persistent; + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_model_info_override("gpt-5.5", |model_info| { + model_info + .experimental_supported_tools + .push("clock".to_string()); + }) + .with_config(move |config| { + config.model_reasoning_effort = Some(reasoning_effort); + let mut features = config.features.get().clone(); + features.apply_map(&BTreeMap::from([( + "sleep_tool".to_string(), + sleep_tool_enabled, + )])); + config + .features + .set(features) + .expect("test features should be allowed"); + }) + .build_with_auto_env(&server) + .await?; + + test.submit_text_turn("what time is it?").await?; + + let request = responses.single_request(); + assert_eq!( + ["curr_time", "sleep"].map(|name| request.tool_by_name("clock", name).is_some()), + [true, sleep_tool_enabled] + ); + assert_eq!( + current_time_reminders(&request).is_empty(), + !reminders_enabled + ); + Ok(()) +} + +#[test_case("[features]\nsleep_tool = true", Some(true), [true, true]; "boolean_enabled")] +#[test_case("[features]\nsleep_tool = false", Some(true), [true, false]; "boolean_disabled")] +#[test_case("[features.sleep_tool]\nmode = 'always_on'", None, [false, true]; "always_on_without_model_clock")] +#[test_case("[features.sleep_tool]\nenabled = false\nmode = 'always_on'", Some(true), [true, false]; "disabled_overrides_always_on")] +#[test_case("[features.sleep_tool]\nmode = 'model_driven'", None, [false, false]; "model_driven_without_model_clock")] +#[test_case("[features.sleep_tool]\nmode = 'model_driven'", Some(false), [true, false]; "model_driven_preserves_legacy_disable")] +#[test_case("[features.sleep_tool]\nmode = 'always_on'", Some(false), [true, true]; "always_on_overrides_legacy_disable")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sleep_tool_configuration_controls_registration( + sleep_config: &str, + legacy_sleep_tool: Option, + expected_tools: [bool; 2], +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let mut config_toml = sleep_config.to_string(); + if let Some(sleep_tool) = legacy_sleep_tool { + config_toml.push_str(&format!( + "\n[features.current_time_reminder]\nenabled = true\nsleep_tool = {sleep_tool}\n" + )); + } + let test = test_codex() + .with_model_info_override("gpt-5.5", |model_info| { + model_info + .experimental_supported_tools + .retain(|tool| tool != "clock"); + }) + .with_pre_build_hook(move |home| { + std::fs::write(home.join("config.toml"), config_toml) + .expect("sleep tool configuration should be written"); + }) + .with_config(|config| { + config.model_reasoning_effort = Some(ReasoningEffort::High); + }) + .build_with_auto_env(&server) + .await?; + + test.submit_text_turn("check the available clock tools") + .await?; + + let request = responses.single_request(); + assert_eq!( + ["curr_time", "sleep"].map(|name| request.tool_by_name("clock", name).is_some()), + expected_tools + ); + assert_eq!( + current_time_reminders(&request).is_empty(), + legacy_sleep_tool.is_none() + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn current_time_reminder_is_refreshed_after_compaction() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 7fa2ab6f09..848be62d35 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -396,6 +396,32 @@ impl FeatureConfig for CurrentTimeReminderConfigToml { } } +/// How the sleep tool is selected when its feature gate is enabled. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SleepToolMode { + /// Preserve the existing model and legacy clock configuration defaults. + #[default] + ModelDriven, + /// Register sleep regardless of the model or legacy clock configuration. + AlwaysOn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SleepToolConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +impl FeatureConfig for SleepToolConfigToml { + fn enabled(&self) -> Option { + self.enabled + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RemovedAppsMcpPathOverrideConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 04a5b86b53..da71498d32 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -33,6 +33,8 @@ pub use feature_configs::NetworkProxyUnixSocketPermissionToml; pub use feature_configs::NonPrefixedMcpToolNamesConfigToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; pub use feature_configs::RolloutBudgetConfigToml; +pub use feature_configs::SleepToolConfigToml; +pub use feature_configs::SleepToolMode; pub use feature_configs::TokenBudgetConfigToml; pub use feature_configs::ToolRegistryConfigToml; use legacy::LegacyFeatureToggles; @@ -95,6 +97,8 @@ pub enum Feature { ShellTool, /// Enable the built-in local image viewer. ViewImage, + /// Allow registration of the built-in sleep tool. + SleepTool, /// Enable Claude-style lifecycle hooks loaded from hooks.json files. CodexHooks, /// Store CLI auth in the encrypted local secrets backend when keyring storage is selected. @@ -747,6 +751,8 @@ pub struct FeaturesToml { pub rollout_budget: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_time_reminder: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sleep_tool: Option>, #[serde(default, rename = "apps_mcp_path_override", skip_serializing)] #[schemars(skip)] removed_apps_mcp_path_override: Option>, @@ -801,6 +807,9 @@ impl FeaturesToml { if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::NetworkProxy.key().to_string(), enabled); } + if let Some(enabled) = self.sleep_tool.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::SleepTool.key().to_string(), enabled); + } entries } } @@ -873,6 +882,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SleepTool, + key: "sleep_tool", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::SecretAuthStorage, key: "secret_auth_storage", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 37760a4463..5ad5132a2e 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -23,6 +23,11 @@ fn transcript_v2_resolves_explicit_config_overrides() { } } +#[test] +fn sleep_tool_config_rejects_unknown_mode() { + assert!(toml::from_str::("[sleep_tool]\nmode = 'off'").is_err()); +} + #[test] fn under_development_features_are_disabled_by_default() { for spec in crate::FEATURES { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index e65038840a..eb319b6e32 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -304,6 +304,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R token_budget: None, rollout_budget: None, current_time_reminder: None, + sleep_tool_mode: Default::default(), features: Default::default(), suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None },