Emit one developer notice per incremental namespace update batch (#52295)

## What changed

Move incremental tool guidance out of namespace descriptions and into a developer message preceding each namespace declaration batch after the initial catalog. The notice covers newly added namespaces as well as changed ones, explaining that earlier tools remain available unless explicitly removed and that newer definitions replace earlier ones.

Retain the notice alongside tool declarations in full-history agent forks, and discard it when legacy compaction forks rebuild those declarations.

## Testing

Add unit coverage for multiple new namespaces sharing one notice. Update request-history snapshots for tool changes, removals and restoration, and regular Responses requests. Extend resume and fork tests to cover notice preservation without duplication and removal when rebuilding declarations.

GitOrigin-RevId: 05f79f5eb378a355cf935eabf63951015a9b454b
This commit is contained in:
Alex Kira
2026-10-08 22:01:24 +00:00
committed by copyberry
parent 77c01c1d37
commit 0b755b1945
14 changed files with 367 additions and 310 deletions
+5 -1
View File
@@ -20,6 +20,7 @@ use crate::context::DeveloperInstructions;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::world_state::IncrementalToolsHint;
use crate::context::world_state::PersistentModeState;
use crate::session::multi_agents::resolve_usage_hints;
use crate::thread_manager::StartThreadOptions;
@@ -1150,7 +1151,10 @@ impl LocalAgentControl {
return false;
};
content.retain_mut(|content_item| {
if content_item.kind().as_str() == BaseInstructionsFragment::KIND {
if matches!(
content_item.kind().as_str(),
BaseInstructionsFragment::KIND | IncrementalToolsHint::KIND
) {
return preserve_context_baselines;
}
let ContentItem::InputText { text } = content_item.content_mut() else {
+38 -3
View File
@@ -25,6 +25,7 @@ use crate::context::ContextualUserFragment;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::context::world_state::IncrementalToolsHint;
use crate::init_state_db;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::session::SessionSettingsUpdate;
@@ -3187,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 replacement_history = vec![
tools_update.clone(),
tool_declarations.clone(),
ContextualUserFragment::into(crate::context::GuardianApprovedAction::new("parent-private-release".to_owned())),
ResponseItem::Message {
@@ -3335,12 +3338,19 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke
strip_response_item_ids(
&history
.raw_items()
.filter(|item| matches!(item, ResponseItem::AdditionalTools { .. }))
.filter(|item| {
matches!(item, ResponseItem::AdditionalTools { .. })
|| matches!(item, ResponseItem::Message {
internal_chat_message_metadata_passthrough: Some(metadata), ..
} if metadata.content_item_kinds.as_ref().is_some_and(|kinds| {
kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND)
}))
})
.cloned()
.collect::<Vec<_>>()
),
vec![tool_declarations],
"full-history forks must retain declarations embedded in compaction checkpoints"
vec![tools_update, tool_declarations],
"full-history forks must retain tool update notices with declarations embedded in compaction checkpoints"
);
assert!(
!history_contains_text(
@@ -3684,6 +3694,14 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
phase: None,
internal_chat_message_metadata_passthrough: None,
}));
rollout_items.push(rollout_response_item(ContextualUserFragment::into(
IncrementalToolsHint,
)));
rollout_items.push(rollout_response_item(ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
tools: vec![serde_json::json!({"type": "function", "name": "parent_tool"})],
}));
rollout_items.push(RolloutItem::TurnContext(
turn_context.to_turn_context_item(),
));
@@ -3739,6 +3757,23 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
)
.await
.expect("read child checkpoint");
assert_eq!(
items
.iter()
.filter_map(|item| match item {
RolloutItem::ResponseItem(envelope)
if matches!(&envelope.item, ResponseItem::Message {
internal_chat_message_metadata_passthrough: Some(metadata), ..
} if metadata.content_item_kinds.as_ref().is_some_and(|kinds| {
kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND)
})) =>
Some(envelope.item.clone()),
_ => None,
})
.collect::<Vec<_>>(),
Vec::<ResponseItem>::new(),
"{case}: a legacy compaction fork must drop notices for tool declarations it rebuilds"
);
let inherited_state = items.into_iter().find_map(|item| match item {
RolloutItem::Compacted(item) => item.resume_metadata,
_ => None,
@@ -58,6 +58,7 @@ pub(crate) use persistent_mode::PersistentModeState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
pub(crate) use tools::ToolsState;
pub(crate) use top_level_tools::IncrementalToolsHint;
pub(crate) use top_level_tools::TopLevelToolsState;
/// One contribution to model context and its placement policy.
@@ -64,11 +64,35 @@ Absent -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.cha
None
{"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.added":"5fc75ad4d08cd0b73ef983d58fed9272072e5ce7","functions.changed":"ebe9005f973a60ba4ee93b024d7021d21dc27826","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
{
"content": [
{
"text": "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.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
{
"description": "Tools.\nThis is an incremental namespace 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.",
"description": "Tools.",
"name": "functions",
"tools": [
{
@@ -108,6 +132,30 @@ The following namespaces are no longer available. Do not call tools in them unle
- functions
{"other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
{
"content": [
{
"text": "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.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
@@ -166,6 +214,30 @@ The following namespaces are no longer available. Do not call tools in them unle
None
{} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.restored":"56fbc7d43a57aab411e5352bfbb88a6df939fd4e","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce"} (item - prefix)
{
"content": [
{
"text": "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.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
@@ -1,6 +1,6 @@
//! Diffs the one-level Responses Lite catalog: namespaces of callable tools and built-ins.
//! Missing state starts a fresh catalog; this section does not migrate legacy history.
//! Incremental hints decorate emitted declarations only, leaving catalog hashes unchanged.
//! One developer notice precedes each incremental namespace batch; catalog hashes are unchanged.
//! Whole namespace removals subsume their members in the removal notice.
//! Metrics count rendered tool redefinitions, excluding namespace headers and initial catalogs.
@@ -27,7 +27,6 @@ const ACTION_ADDED: &str = "added";
const ACTION_REMOVED: &str = "removed";
const ACTION_SCHEMA_CHANGED: &str = "schema_changed";
const NAMESPACE_UPDATE_HINT: &str = "This is an incremental namespace 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.";
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:";
@@ -170,14 +169,6 @@ impl WorldStateSection for TopLevelToolsState {
if !changed_members.is_empty() {
let mut namespace = definition.clone();
namespace["tools"] = Value::Array(changed_members);
if previous.is_some_and(|previous| previous.contains_key(name)) {
let description = definition["description"].as_str().unwrap_or_default();
namespace["description"] = Value::String(if description.is_empty() {
NAMESPACE_UPDATE_HINT.to_string()
} else {
format!("{description}\n{NAMESPACE_UPDATE_HINT}")
});
}
tools.push(namespace);
} else if changed(name) {
// Namespace declarations require a member. Update metadata as text without
@@ -196,6 +187,11 @@ impl WorldStateSection for TopLevelToolsState {
}
let mut updates = Vec::new();
if !tools.is_empty() {
if previous.is_some() && tools.iter().any(|tool| tool["type"] == "namespace") {
updates.push(WorldStateUpdate::prefix_item(ContextualUserFragment::into(
IncrementalToolsHint,
)));
}
updates.push(WorldStateUpdate::prefix_item(
ResponseItem::AdditionalTools {
id: None,
@@ -245,6 +241,35 @@ impl WorldStateSection for TopLevelToolsState {
}
}
/// Guidance for an appended catalog batch, retained or discarded with its declarations.
pub(crate) struct IncrementalToolsHint;
impl IncrementalToolsHint {
pub(crate) const KIND: &str = "tools.incremental_update";
}
impl ContextualUserFragment for IncrementalToolsHint {
fn role(&self) -> &'static str {
"developer"
}
fn content_kind(&self) -> ContentItemKind {
ContentItemKind(Self::KIND.to_string())
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
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()
}
}
#[derive(Default)]
struct RemovedTools {
namespaces: Vec<String>,
@@ -228,7 +228,13 @@ fn empty_catalog_is_persisted_and_can_add_tools_again() {
})]
);
let (_, updates) = original.render_diff(PreviousSectionState::Known(&snapshot.unwrap()));
assert_eq!(merge_world_state_updates(updates), vec![item(definitions)]);
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(definitions),
]
);
}
#[test]
@@ -267,11 +273,11 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
)
.unwrap();
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
let mut expected = namespace("functions", vec![changed, added]);
expected["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}"));
let expected = namespace("functions", vec![changed, added]);
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(vec![expected]),
ContextualUserFragment::into(RemovedTools {
tools: vec!["functions.removed".to_string()],
@@ -287,6 +293,38 @@ 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 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(
std::iter::once(original).chain(additions.clone()).collect(),
/*metrics*/ None,
)
.unwrap();
let (snapshot, updates) = state.render_diff(PreviousSectionState::Known(&previous));
let (prefix, remaining) = super::super::split_prefix_updates(updates);
assert_eq!(
prefix,
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(additions),
]
);
assert!(remaining.is_empty());
assert!(
state
.render_diff(PreviousSectionState::Known(&snapshot.unwrap()))
.1
.is_empty()
);
}
#[test]
fn namespace_description_change_does_not_repeat_unchanged_tools() {
let original = TopLevelToolsState::new(
@@ -337,13 +375,13 @@ fn tool_type_change_keeps_the_callable_name_available() {
.unwrap();
let custom = json!({"type": "custom", "name": "lookup", "description": "Look up a value.",
"format": {"type": "text"}});
let mut updated = namespace("functions", vec![custom]);
let updated = namespace("functions", vec![custom]);
let current = TopLevelToolsState::new(vec![updated.clone()], /*metrics*/ None).unwrap();
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
updated["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}"));
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(vec![updated]),
ContextualUserFragment::into(RemovedTools {
tools: vec!["functions.removed".to_string()],