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
This commit is contained in:
rka-oai
2026-08-28 02:09:07 +00:00
committed by copyberry
parent 41d3dc56a0
commit ec9620c231
12 changed files with 313 additions and 14 deletions
+4 -1
View File
@@ -67,7 +67,10 @@ pub fn is_structured_feature_path<S: AsRef<str>>(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<String>) {
+32
View File
@@ -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 = (
+9
View File
@@ -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::<codex_features::FeatureToml<
codex_features::SleepToolConfigToml,
>>(),
);
continue;
}
if feature.id == codex_features::Feature::AppsMcpPathOverride {
validation.properties.insert(
feature.key.to_string(),
+47
View File
@@ -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"
},
+27
View File
@@ -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");
+13
View File
@@ -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<RolloutBudgetConfig>,
/// Current-time reminder and clock tool configuration, when enabled.
pub current_time_reminder: Option<CurrentTimeReminderConfig>,
/// 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
+22 -13
View File
@@ -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);
}
@@ -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<bool>,
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(()));
+26
View File
@@ -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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<SleepToolMode>,
}
impl FeatureConfig for SleepToolConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct RemovedAppsMcpPathOverrideConfigToml {
+15
View File
@@ -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<FeatureToml<RolloutBudgetConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_time_reminder: Option<FeatureToml<CurrentTimeReminderConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sleep_tool: Option<FeatureToml<SleepToolConfigToml>>,
#[serde(default, rename = "apps_mcp_path_override", skip_serializing)]
#[schemars(skip)]
removed_apps_mcp_path_override: Option<FeatureToml<RemovedAppsMcpPathOverrideConfigToml>>,
@@ -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",
+5
View File
@@ -23,6 +23,11 @@ fn transcript_v2_resolves_explicit_config_overrides() {
}
}
#[test]
fn sleep_tool_config_rejects_unknown_mode() {
assert!(toml::from_str::<FeaturesToml>("[sleep_tool]\nmode = 'off'").is_err());
}
#[test]
fn under_development_features_are_disabled_by_default() {
for spec in crate::FEATURES {
@@ -304,6 +304,7 @@ fn new_config(model: Option<String>, 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 },