mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
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:
@@ -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>) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user