Add an option to suppress incremental model change announcements (#58405)

## What changed

Add the default-off `suppress_model_change_announcements` feature flag. When enabled, incremental context updates omit the model ID announcement after a model switch while still delivering changed base instructions and tool definitions.

Advance the model snapshot even when a switch emits no context update, so subsequent turns recognize the current model.

## Testing

Add coverage for omitted and explicit flag values, snapshot advancement without repeated updates, and request history across model switches, repeated turns, and compaction with announcements suppressed.

GitOrigin-RevId: 89c70f1e9d7981fd945fb8e41b1f98492717a07e
This commit is contained in:
chetan-oai
2026-10-30 18:19:38 +00:00
committed by copyberry
parent 7a030b9769
commit 8cb81f6490
8 changed files with 214 additions and 11 deletions
+6
View File
@@ -1368,6 +1368,9 @@
"subagent_thread_environments": {
"type": "boolean"
},
"suppress_model_change_announcements": {
"type": "boolean"
},
"system_proxy_fallback": {
"type": "boolean"
},
@@ -8069,6 +8072,9 @@
"subagent_thread_environments": {
"type": "boolean"
},
"suppress_model_change_announcements": {
"type": "boolean"
},
"system_proxy_fallback": {
"type": "boolean"
},
+12 -4
View File
@@ -32,6 +32,7 @@ pub(crate) struct ModelInstructionsState {
/// Ordinary-window fallback when the saved model snapshot is unavailable.
previous_model: Option<String>,
instructions: String,
suppress_model_change_announcements: bool,
}
impl ModelInstructionsState {
@@ -40,10 +41,15 @@ impl ModelInstructionsState {
snapshot: ModelInstructionsSnapshot::ModelOnly(model.to_string()),
previous_model: previous_model.map(str::to_string),
instructions,
suppress_model_change_announcements: false,
}
}
pub(crate) fn for_incremental_tools(model: &str, instructions: String) -> Self {
pub(crate) fn for_incremental_tools(
model: &str,
instructions: String,
suppress_model_change_announcements: bool,
) -> Self {
let instructions = BaseInstructionsFragment(instructions);
let instructions_hash = WorldStateHash::from_fragment(&instructions);
Self {
@@ -53,6 +59,7 @@ impl ModelInstructionsState {
},
previous_model: None,
instructions: instructions.0,
suppress_model_change_announcements,
}
}
}
@@ -114,12 +121,13 @@ impl WorldStateSection for ModelInstructionsState {
let instructions_changed = instructions_hash.is_some_and(|current| {
previous_instructions_hash.map_or(model_changed, |previous| previous != current)
});
if instructions_hash.is_some() && !model_changed && !instructions_changed {
return (None, Vec::new());
let announce_model = model_changed && !self.suppress_model_change_announcements;
if instructions_hash.is_some() && !announce_model && !instructions_changed {
return (model_changed.then(|| self.snapshot.clone()), Vec::new());
}
let fragment: Option<Box<dyn ContextualUserFragment>> = if instructions_hash.is_some() {
Some(Box::new(ModelInstructions {
model: model_changed.then(|| model.clone()),
model: announce_model.then(|| model.clone()),
instructions: instructions_changed.then(|| self.instructions.clone()),
}))
} else {
@@ -51,12 +51,20 @@ fn unchanged_model_does_not_render() {
#[test]
fn legacy_model_snapshot_keeps_its_window_until_model_change_or_compaction() {
let legacy: ModelInstructionsSnapshot = serde_json::from_str("\"gpt-old\"").unwrap();
let same_model = ModelInstructionsState::for_incremental_tools("gpt-old", "new prompt".into());
let same_model = ModelInstructionsState::for_incremental_tools(
"gpt-old",
"new prompt".into(),
/*suppress_model_change_announcements*/ false,
);
let (snapshot, updates) = same_model.render_diff(PreviousSectionState::Known(&legacy));
assert!(snapshot.is_none());
assert!(updates.is_empty());
let new_model = ModelInstructionsState::for_incremental_tools("gpt-new", "new prompt".into());
let new_model = ModelInstructionsState::for_incremental_tools(
"gpt-new",
"new prompt".into(),
/*suppress_model_change_announcements*/ false,
);
let (snapshot, updates) = new_model.render_diff(PreviousSectionState::Known(&legacy));
assert!(snapshot.is_some());
assert_eq!(updates.len(), 1);
@@ -78,3 +86,26 @@ fn legacy_model_snapshot_keeps_its_window_until_model_change_or_compaction() {
);
}
}
#[test]
fn suppressed_model_changes_advance_snapshot_without_repeated_updates() {
let initial = ModelInstructionsState::for_incremental_tools(
"gpt-old",
"instructions".into(),
/*suppress_model_change_announcements*/ true,
);
let (snapshot, _) = initial.render_diff(PreviousSectionState::Absent);
let previous = snapshot.unwrap();
let switched = ModelInstructionsState::for_incremental_tools(
"gpt-new",
"instructions".into(),
/*suppress_model_change_announcements*/ true,
);
let (snapshot, updates) = switched.render_diff(PreviousSectionState::Known(&previous));
assert!(updates.is_empty());
let snapshot = snapshot.unwrap();
assert_eq!(snapshot, switched.snapshot);
let (unchanged, updates) = switched.render_diff(PreviousSectionState::Known(&snapshot));
assert!(unchanged.is_none());
assert!(updates.is_empty());
}
+8 -1
View File
@@ -100,7 +100,14 @@ impl Session {
Some(BaseInstructionsProvenance::Model { .. }) => render_current_instructions(),
Some(BaseInstructionsProvenance::Custom) | None => base_instructions.text,
};
ModelInstructionsState::for_incremental_tools(&model_info.slug, instructions)
ModelInstructionsState::for_incremental_tools(
&model_info.slug,
instructions,
turn_context
.config
.features
.enabled(Feature::SuppressModelChangeAnnouncements),
)
} else {
let model_instructions = render_current_instructions();
let base_instructions = self.get_prompt_base_instructions().await.text;
@@ -148,11 +148,13 @@ async fn prediction_fork_keeps_inherited_tools() -> Result<()> {
Ok(())
}
#[test_case(true; "responses_lite")]
#[test_case(false; "responses_api")]
#[test_case(true, None; "responses_lite")]
#[test_case(true, Some(true); "announcements_suppressed")]
#[test_case(false, None; "responses_api")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incremental_tools_append_changed_catalog_without_rewriting_history(
use_responses_lite: bool,
suppress_model_change_announcements: Option<bool>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -199,6 +201,12 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
}
})
.with_config(move |config| {
if let Some(suppress) = suppress_model_change_announcements {
config
.features
.set_enabled(Feature::SuppressModelChangeAnnouncements, suppress)
.expect("configure model announcements");
}
if !use_responses_lite {
config.base_instructions =
Some("Use the available tools to help the user.".to_string());
@@ -368,13 +376,17 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
);
}
insta::assert_snapshot!(
if use_responses_lite {
if suppress_model_change_announcements == Some(true) {
"incremental_tools_suppressed_announcements"
} else if use_responses_lite {
"incremental_tools"
} else {
"incremental_tools_responses_api"
},
context_snapshot::format_request_history_snapshot(
if use_responses_lite {
if suppress_model_change_announcements == Some(true) {
"A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base."
} else if use_responses_lite {
"Tool definitions precede the initial base instructions. A model switch appends one developer notice, changed tools, and replacement instructions without rewriting the prefix; compaction starts a new window with the updated base."
} else {
"Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests."
@@ -0,0 +1,113 @@
---
source: core/tests/suite/scenarios_incremental_tools.rs
expression: "context_snapshot::format_request_history_snapshot(if\nsuppress_model_change_announcements == Some(true)\n{\n \"A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base.\"\n} else if use_responses_lite\n{\n \"Tool definitions precede the initial base instructions. A model switch appends one developer notice, changed tools, and replacement instructions without rewriting the prefix; compaction starts a new window with the updated base.\"\n} else\n{\n \"Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests.\"\n}, &requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: A model switch suppresses the model ID announcement while appending changed tools and replacement instructions. Repeated turns preserve the prefix; compaction starts a new window with the updated base.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-5.4"
parallel_tool_calls: false
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"context":"all_turns","effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
-- request 1 (turn) --
00:additional_tools/developer (2; hash=49f981f03ae5c5ce):
- namespace/functions; hash=cc6e8cf0e5405f3a
- custom/exec
- function/wait
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
<MODEL_INSTRUCTIONS>
02:message/developer:
<PERMISSIONS_INSTRUCTIONS>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Begin work.
-- request 2 (turn) --
05:message/user:
Continue with the same tools.
## Window 2 (after request 2)
Settings: relative to window 1
model: "updated-tools-model"
-- request 3 (turn) --
00:additional_tools/developer (2; hash=49f981f03ae5c5ce):
- namespace/functions; hash=cc6e8cf0e5405f3a
- custom/exec
- function/wait
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
<MODEL_INSTRUCTIONS>
02:message/developer:
<PERMISSIONS_INSTRUCTIONS>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Begin work.
05:message/user:
Continue with the same tools.
06:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
07:additional_tools/developer (1; hash=26cd263a29587cc7):
- namespace/functions; hash=771cad2ff250d2f3
- custom/exec
08:message/developer:
<model_instructions>
The following instructions replace the previous base instructions:
Review results before responding.
</model_instructions>
09:message/user:
Continue with the updated execution instructions.
-- request 4 (turn) --
10:message/user:
Continue with the same updated tools.
-- request 5 (compaction) --
11:compaction_trigger
## Window 3 (after request 5: input diverged at item 00)
Settings: same as window 2
-- request 6 (turn) --
00:additional_tools/developer (2; hash=1322c3a339378659):
- namespace/functions; hash=9d5d7a0adab05fef
- custom/exec
- function/wait
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
<MODEL_INSTRUCTIONS>
02:message/user:
Begin work.
03:message/user:
Continue with the same tools.
04:message/user:
Continue with the updated execution instructions.
05:message/developer:
<PERMISSIONS_INSTRUCTIONS>
06:message/user:
<ENVIRONMENT_CONTEXT>
07:message/user:
Continue with the same updated tools.
08:compaction:encrypted=true; chars=28; hash=e4a73156e0a498bc
09:message/user:
Continue after compaction.
+8
View File
@@ -295,6 +295,8 @@ pub enum Feature {
DeferredToolWorldState,
/// Track top-level tool definitions in world state and emit incremental context updates.
IncrementalTools,
/// Suppress the model ID announcement in incremental context after a model switch.
SuppressModelChangeAnnouncements,
/// Preserve inherited tool declarations in MCP-disabled prediction forks.
PredictionPreserveParentTools,
/// Diff CodeModeOnly exec definitions within the incremental tool catalog.
@@ -1743,6 +1745,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::SuppressModelChangeAnnouncements,
key: "suppress_model_change_announcements",
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::PredictionPreserveParentTools,
key: "prediction_preserve_parent_tools",
+18
View File
@@ -979,3 +979,21 @@ fn retired_worktree_and_voice_config_overrides_are_ignored() {
assert_eq!(features, Features::with_defaults());
}
}
#[test]
fn model_change_suppression_config_preserves_omission_and_explicit_values() {
for (input, expected) in [
("", false),
("suppress_model_change_announcements = false", false),
("suppress_model_change_announcements = true", true),
] {
let config: FeaturesToml = toml::from_str(input).unwrap();
let mut features = Features::with_defaults();
features.apply_toml(&config);
assert_eq!(
features.enabled(Feature::SuppressModelChangeAnnouncements),
expected,
"{input}"
);
}
}