Allow model catalogs to override incremental tool notices (#52736)

## What changed

Add `model_messages.tools.incremental_tools` overrides for the update hint, tool and namespace removal headers, and namespace instruction update and clearing messages. Missing, null, or over-512-byte values fall back to bundled wording; empty strings omit static text while retaining runtime names and instructions.

Substitute `{name}` in namespace message templates and append runtime instructions verbatim. Wording changes alone do not trigger tool catalog updates.

## Testing

Extend unit tests and MCP request-history snapshots to cover custom wording, empty notices, tool removal and restoration, and namespace instruction changes and clearing without repeating unchanged tools or notices.

GitOrigin-RevId: 22dea0a6fb4cfcd646f5e65346424834c8b7d5a2
This commit is contained in:
Alex Kira
2026-10-10 01:14:28 +00:00
committed by copyberry
parent 4bad6d78e9
commit e4490eff65
11 changed files with 493 additions and 52 deletions
+6 -2
View File
@@ -3188,7 +3188,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke
role: "developer".to_string(),
tools: vec![serde_json::json!({"type": "function", "name": "compacted_tool"})],
};
let tools_update = ContextualUserFragment::into(IncrementalToolsHint);
let tools_update = ContextualUserFragment::into(IncrementalToolsHint(
codex_prompts::ResolvedIncrementalToolMessages::default().tool_update_hint,
));
let replacement_history = vec![
tools_update.clone(),
tool_declarations.clone(),
@@ -3700,7 +3702,9 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
internal_chat_message_metadata_passthrough: None,
}));
rollout_items.push(rollout_response_item(ContextualUserFragment::into(
IncrementalToolsHint,
IncrementalToolsHint(
codex_prompts::ResolvedIncrementalToolMessages::default().tool_update_hint,
),
)));
rollout_items.push(rollout_response_item(ResponseItem::AdditionalTools {
id: None,
@@ -13,6 +13,7 @@ use crate::context::ContextualUserFragment;
use crate::context::DeveloperInstructions;
use codex_extension_api::ExtensionMetrics;
use codex_otel::TOOL_INCREMENTAL_UPDATES_METRIC;
use codex_prompts::ResolvedIncrementalToolMessages;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::error::Result;
use codex_protocol::models::ContentItemKind;
@@ -26,11 +27,9 @@ const ACTION_ADDED: &str = "added";
const ACTION_REMOVED: &str = "removed";
const ACTION_SCHEMA_CHANGED: &str = "schema_changed";
const REMOVED_TOOLS_HEADER: &str = "The following tools are no longer available. Do not call them:";
const REMOVED_NAMESPACES_HEADER: &str = "The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update:";
/// The exact serialized Responses Lite declarations visible in one captured step.
pub(crate) struct TopLevelToolsState {
messages: ResolvedIncrementalToolMessages,
definitions: Vec<Value>,
hashes: BTreeMap<String, WorldStateHash>,
metrics: Option<Arc<dyn ExtensionMetrics>>,
@@ -42,6 +41,7 @@ impl TopLevelToolsState {
reason = "the Responses Lite serializer emits objects with array-valued namespace members"
)]
pub(crate) fn new(
messages: ResolvedIncrementalToolMessages,
definitions: Vec<Value>,
metrics: Option<Arc<dyn ExtensionMetrics>>,
) -> Result<Self> {
@@ -68,6 +68,7 @@ impl TopLevelToolsState {
insert_hash(name.to_string(), &header)?;
}
Ok(Self {
messages,
definitions,
hashes,
metrics,
@@ -174,19 +175,31 @@ impl WorldStateSection for TopLevelToolsState {
// repeating unchanged tool definitions.
let instructions = definition["description"].as_str().unwrap_or_default();
let text = if instructions.is_empty() {
format!("The {name} namespace no longer has additional instructions.")
self.messages
.namespace_instructions_cleared
.replace("{name}", name)
} else {
format!("Updated instructions for the {name} namespace:\n{instructions}")
let mut text = self
.messages
.namespace_instructions_prefix
.replace("{name}", name);
text.push_str(instructions);
text
};
namespace_updates
.push(WorldStateUpdate::fragment(DeveloperInstructions::new(text)));
if !text.is_empty() {
namespace_updates
.push(WorldStateUpdate::fragment(DeveloperInstructions::new(text)));
}
}
}
let mut updates = Vec::new();
if !tools.is_empty() {
if previous.is_some() && tools.iter().any(|tool| tool["type"] == "namespace") {
if previous.is_some()
&& tools.iter().any(|tool| tool["type"] == "namespace")
&& !self.messages.tool_update_hint.is_empty()
{
updates.push(WorldStateUpdate::prefix_item(ContextualUserFragment::into(
IncrementalToolsHint,
IncrementalToolsHint(self.messages.tool_update_hint.clone()),
)));
}
updates.push(WorldStateUpdate::prefix_item(
@@ -225,6 +238,7 @@ impl WorldStateSection for TopLevelToolsState {
if !namespaces.is_empty() || !tools.is_empty() {
updates.push(
WorldStateUpdate::fragment(RemovedTools {
messages: self.messages.clone(),
namespaces: namespaces.into_iter().map(str::to_string).collect(),
tools,
})
@@ -239,7 +253,7 @@ impl WorldStateSection for TopLevelToolsState {
}
/// Guidance for an appended catalog batch, retained or discarded with its declarations.
pub(crate) struct IncrementalToolsHint;
pub(crate) struct IncrementalToolsHint(pub(crate) String);
impl IncrementalToolsHint {
pub(crate) const KIND: &str = "tools.incremental_update";
@@ -263,12 +277,13 @@ impl ContextualUserFragment for IncrementalToolsHint {
}
fn body(&self) -> String {
"This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.".to_string()
self.0.clone()
}
}
#[derive(Default)]
struct RemovedTools {
messages: ResolvedIncrementalToolMessages,
namespaces: Vec<String>,
tools: Vec<String>,
}
@@ -293,8 +308,11 @@ impl ContextualUserFragment for RemovedTools {
/// Lists removed namespaces and individual tools in separate sections of one notice.
fn body(&self) -> String {
let sections = [
(REMOVED_NAMESPACES_HEADER, &self.namespaces),
(REMOVED_TOOLS_HEADER, &self.tools),
(
self.messages.removed_namespaces_header.as_str(),
&self.namespaces,
),
(self.messages.removed_tools_header.as_str(), &self.tools),
];
let mut body = String::new();
for (header, names) in sections {
@@ -42,12 +42,32 @@ fn snapshots_catalog_transitions() {
"description": "Search tools.",
"parameters": {"type": "object", "properties": {}}});
let web = json!({"type": "web_search", "external_web_access": true});
let original = TopLevelToolsState::new(vec![search.clone()], /*metrics*/ None).unwrap();
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![search.clone()],
/*metrics*/ None,
)
.unwrap();
let mut changed = search;
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
let updated = TopLevelToolsState::new(vec![changed, web.clone()], /*metrics*/ None).unwrap();
let only_web = TopLevelToolsState::new(vec![web], /*metrics*/ None).unwrap();
let empty = TopLevelToolsState::new(Vec::new(), /*metrics*/ None).unwrap();
let updated = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![changed, web.clone()],
/*metrics*/ None,
)
.unwrap();
let only_web = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![web],
/*metrics*/ None,
)
.unwrap();
let empty = TopLevelToolsState::new(
/*messages*/ Default::default(),
Vec::new(),
/*metrics*/ None,
)
.unwrap();
insta::assert_snapshot!(render_section_cases(&[
(Absent, Known(&original)),
@@ -90,6 +110,7 @@ async fn snapshots_namespace_transitions() {
.with_metrics_without_metadata_tags(metrics.clone()),
);
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![
namespace(
"functions",
@@ -107,6 +128,7 @@ async fn snapshots_namespace_transitions() {
let mut changed = declaration("changed");
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
let updated = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![
namespace(
"functions",
@@ -118,17 +140,25 @@ async fn snapshots_namespace_transitions() {
)
.unwrap();
let only_other = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace("other", vec![declaration("lookup")])],
Some(Arc::clone(&telemetry)),
)
.unwrap();
let only_functions = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace("functions", vec![declaration("unchanged")])],
Some(Arc::clone(&telemetry)),
)
.unwrap();
let empty = TopLevelToolsState::new(Vec::new(), Some(Arc::clone(&telemetry))).unwrap();
let empty = TopLevelToolsState::new(
/*messages*/ Default::default(),
Vec::new(),
Some(Arc::clone(&telemetry)),
)
.unwrap();
let restored = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace(
"functions",
vec![declaration("unchanged"), declaration("restored")],
@@ -191,38 +221,71 @@ fn initial_catalog_preserves_order_and_unchanged_catalog_emits_nothing() {
namespace("zebra", vec![declaration("lookup")]),
namespace("alpha", vec![declaration("lookup")]),
];
let state = TopLevelToolsState::new(definitions.clone(), /*metrics*/ None).unwrap();
let state = TopLevelToolsState::new(
/*messages*/ Default::default(),
definitions.clone(),
/*metrics*/ None,
)
.unwrap();
let (snapshot, updates) = state.render_diff(PreviousSectionState::Absent);
assert_eq!(
merge_world_state_updates(updates),
vec![item(definitions.clone())]
);
let (_, updates) = state.render_diff(PreviousSectionState::Unknown);
assert_eq!(merge_world_state_updates(updates), vec![item(definitions)]);
assert_eq!(
merge_world_state_updates(updates),
vec![item(definitions.clone())]
);
let snapshot = snapshot.unwrap();
assert_eq!(
snapshot.keys().map(String::as_str).collect::<Vec<_>>(),
vec!["alpha", "alpha.lookup", "zebra", "zebra.lookup"]
);
let (unchanged_snapshot, updates) = state.render_diff(PreviousSectionState::Known(&snapshot));
assert_eq!(unchanged_snapshot, Some(snapshot));
assert_eq!(unchanged_snapshot, Some(snapshot.clone()));
assert!(updates.is_empty());
let reworded = TopLevelToolsState::new(
ResolvedIncrementalToolMessages {
tool_update_hint: "New wording".to_string(),
removed_tools_header: "Removed".to_string(),
..Default::default()
},
definitions,
/*metrics*/ None,
)
.unwrap();
let (reworded_snapshot, updates) = reworded.render_diff(PreviousSectionState::Known(&snapshot));
assert_eq!(reworded_snapshot, Some(snapshot));
assert!(updates.is_empty());
}
#[test]
fn empty_catalog_is_persisted_and_can_add_tools_again() {
let definitions = vec![namespace("functions", vec![declaration("lookup")])];
let original = TopLevelToolsState::new(definitions.clone(), /*metrics*/ None).unwrap();
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
definitions.clone(),
/*metrics*/ None,
)
.unwrap();
let previous = original
.render_diff(PreviousSectionState::Absent)
.0
.unwrap();
let empty = TopLevelToolsState::new(Vec::new(), /*metrics*/ None).unwrap();
let empty = TopLevelToolsState::new(
/*messages*/ Default::default(),
Vec::new(),
/*metrics*/ None,
)
.unwrap();
let (snapshot, updates) = empty.render_diff(PreviousSectionState::Known(&previous));
assert_eq!(snapshot, Some(BTreeMap::new()));
assert_eq!(
merge_world_state_updates(updates),
vec![ContextualUserFragment::into(RemovedTools {
messages: Default::default(),
namespaces: vec!["functions".to_string()],
..Default::default()
})]
@@ -231,7 +294,9 @@ fn empty_catalog_is_persisted_and_can_add_tools_again() {
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
ContextualUserFragment::into(IncrementalToolsHint(
ResolvedIncrementalToolMessages::default().tool_update_hint
)),
item(definitions),
]
);
@@ -240,6 +305,7 @@ fn empty_catalog_is_persisted_and_can_add_tools_again() {
#[test]
fn namespace_diff_contains_only_changed_and_added_tools() {
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![
namespace(
"functions",
@@ -262,6 +328,7 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
let added = declaration("added");
let current = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![
namespace(
"functions",
@@ -277,9 +344,12 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
ContextualUserFragment::into(IncrementalToolsHint(
ResolvedIncrementalToolMessages::default().tool_update_hint
)),
item(vec![expected]),
ContextualUserFragment::into(RemovedTools {
messages: Default::default(),
tools: vec!["functions.removed".to_string()],
..Default::default()
}),
@@ -296,13 +366,19 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
#[test]
fn new_namespaces_share_one_notice_before_their_declarations() {
let original = namespace("original", vec![declaration("lookup")]);
let state = TopLevelToolsState::new(vec![original.clone()], /*metrics*/ None).unwrap();
let state = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![original.clone()],
/*metrics*/ None,
)
.unwrap();
let previous = state.render_diff(PreviousSectionState::Absent).0.unwrap();
let additions = vec![
namespace("second", vec![declaration("lookup")]),
namespace("third", vec![declaration("lookup")]),
];
let state = TopLevelToolsState::new(
let mut state = TopLevelToolsState::new(
/*messages*/ Default::default(),
std::iter::once(original).chain(additions.clone()).collect(),
/*metrics*/ None,
)
@@ -312,11 +388,18 @@ fn new_namespaces_share_one_notice_before_their_declarations() {
assert_eq!(
prefix,
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(additions),
ContextualUserFragment::into(IncrementalToolsHint(
ResolvedIncrementalToolMessages::default().tool_update_hint
)),
item(additions.clone()),
]
);
assert!(remaining.is_empty());
state.messages.tool_update_hint.clear();
let (_, updates) = state.render_diff(PreviousSectionState::Known(&previous));
let (prefix, remaining) = super::super::split_prefix_updates(updates);
assert_eq!(prefix, vec![item(additions)]);
assert!(remaining.is_empty());
assert!(
state
.render_diff(PreviousSectionState::Known(&snapshot.unwrap()))
@@ -325,9 +408,34 @@ fn new_namespaces_share_one_notice_before_their_declarations() {
);
}
#[test]
fn namespace_description_change_does_not_repeat_unchanged_tools() {
#[test_case::test_case(
None,
"Updated namespace guidance.",
"Updated instructions for the functions namespace:\nUpdated namespace guidance."
)]
#[test_case::test_case(
Some(("Catalog {name}: ", "Cleared {name}.")),
"Literal {name} and {instructions}.",
"Catalog functions: Literal {name} and {instructions}."
)]
#[test_case::test_case(
Some(("", "Cleared {name}.")),
"Runtime instructions.",
"Runtime instructions."
)]
#[test_case::test_case(
Some(("Catalog {name}: ", "Cleared {name}.")),
"",
"Cleared functions."
)]
#[test_case::test_case(Some(("Catalog {name}: ", "")), "", "")]
fn namespace_description_change_does_not_repeat_unchanged_tools(
wording: Option<(&str, &str)>,
instructions: &str,
expected: &str,
) {
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace("functions", vec![declaration("lookup")])],
/*metrics*/ None,
)
@@ -337,14 +445,23 @@ fn namespace_description_change_does_not_repeat_unchanged_tools() {
.0
.unwrap();
let mut updated = namespace("functions", vec![declaration("lookup")]);
updated["description"] = json!("Updated namespace guidance.");
let current = TopLevelToolsState::new(vec![updated], /*metrics*/ None).unwrap();
updated["description"] = json!(instructions);
let mut messages = ResolvedIncrementalToolMessages::default();
if let Some((prefix, cleared_template)) = wording {
messages.namespace_instructions_prefix = prefix.to_string();
messages.namespace_instructions_cleared = cleared_template.to_string();
}
let current = TopLevelToolsState::new(messages, vec![updated], /*metrics*/ None).unwrap();
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
assert_eq!(
merge_world_state_updates(updates),
vec![ContextualUserFragment::into(DeveloperInstructions::new(
"Updated instructions for the functions namespace:\nUpdated namespace guidance."
))]
if expected.is_empty() {
Vec::new()
} else {
vec![ContextualUserFragment::into(DeveloperInstructions::new(
expected,
))]
}
);
assert!(
current
@@ -357,6 +474,7 @@ fn namespace_description_change_does_not_repeat_unchanged_tools() {
#[test]
fn tool_type_change_keeps_the_callable_name_available() {
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace(
"functions",
vec![declaration("lookup"), declaration("removed")],
@@ -371,14 +489,22 @@ fn tool_type_change_keeps_the_callable_name_available() {
let custom = json!({"type": "custom", "name": "lookup", "description": "Look up a value.",
"format": {"type": "text"}});
let updated = namespace("functions", vec![custom]);
let current = TopLevelToolsState::new(vec![updated.clone()], /*metrics*/ None).unwrap();
let current = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![updated.clone()],
/*metrics*/ None,
)
.unwrap();
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
ContextualUserFragment::into(IncrementalToolsHint(
ResolvedIncrementalToolMessages::default().tool_update_hint
)),
item(vec![updated]),
ContextualUserFragment::into(RemovedTools {
messages: Default::default(),
tools: vec!["functions.removed".to_string()],
..Default::default()
}),
@@ -389,6 +515,7 @@ fn tool_type_change_keeps_the_callable_name_available() {
#[test]
fn namespace_removals_emit_one_notice() {
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![
namespace(
"functions",
@@ -410,6 +537,7 @@ fn namespace_removals_emit_one_notice() {
.0
.unwrap();
let current = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace("functions", vec![declaration("lookup")])],
/*metrics*/ None,
)
@@ -418,6 +546,7 @@ fn namespace_removals_emit_one_notice() {
assert_eq!(
merge_world_state_updates(updates),
vec![ContextualUserFragment::into(RemovedTools {
messages: Default::default(),
namespaces: vec!["function".to_string(), "other".to_string()],
tools: vec![
"functions.also_removed".to_string(),
@@ -431,6 +560,7 @@ fn namespace_removals_emit_one_notice() {
#[test]
fn namespace_replaced_by_builtin_keeps_the_replacement_available() {
let original = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![namespace("web_search", vec![declaration("lookup")])],
/*metrics*/ None,
)
@@ -440,13 +570,19 @@ fn namespace_replaced_by_builtin_keeps_the_replacement_available() {
.0
.unwrap();
let replacement = json!({"type": "web_search"});
let current = TopLevelToolsState::new(vec![replacement.clone()], /*metrics*/ None).unwrap();
let current = TopLevelToolsState::new(
/*messages*/ Default::default(),
vec![replacement.clone()],
/*metrics*/ None,
)
.unwrap();
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
assert_eq!(
merge_world_state_updates(updates),
vec![
item(vec![replacement]),
ContextualUserFragment::into(RemovedTools {
messages: Default::default(),
tools: vec!["web_search.lookup".to_string()],
..Default::default()
}),
+1
View File
@@ -147,6 +147,7 @@ impl Session {
let specs = step_context.tool_router.model_visible_specs();
let definitions = codex_tools::create_tools_json_for_responses_lite(&specs)?;
world_state.add_section(TopLevelToolsState::new(
model_messages.incremental_tools(),
definitions,
Some(Arc::clone(&extension_metrics)),
)?);