Track deferred tool namespaces in world state (#35063)

## What changed

- Add the disabled-by-default `deferred_tool_world_state` feature, which exposes deferred tool namespaces and their descriptions to the model in a `<tools>` world-state section.
- Emit added and removed namespace updates as tool availability changes, bound the rendered context size, and persist nonempty state across thread resumes.
- Omit empty tool state while retaining deferred tool discovery through `tool_search`.

## Testing

- Cover initial, unchanged, removed, recovered, empty, and resumed namespace state, along with description truncation and rendered-size limits.

GitOrigin-RevId: 867e599666dc3569eb0789ba78aaa40254253a6f
This commit is contained in:
Ahmed Ibrahim
2026-07-24 01:20:04 +00:00
committed by copyberry
parent 09241ae4db
commit 1d4b58f32d
16 changed files with 785 additions and 5 deletions
+6
View File
@@ -497,6 +497,9 @@
"deferred_executor": {
"type": "boolean"
},
"deferred_tool_world_state": {
"type": "boolean"
},
"elevated_windows_sandbox": {
"type": "boolean"
},
@@ -5034,6 +5037,9 @@
"deferred_executor": {
"type": "boolean"
},
"deferred_tool_world_state": {
"type": "boolean"
},
"elevated_windows_sandbox": {
"type": "boolean"
},
@@ -9,6 +9,7 @@ mod plugins_instructions;
mod realtime;
#[cfg(test)]
mod test_support;
mod tools;
use crate::context::ContextualUserFragment;
use codex_extension_api::PreviousWorldStateSection;
@@ -35,6 +36,7 @@ pub(crate) use multi_agent_mode::MultiAgentModeState;
pub(crate) use permissions::PermissionsState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
pub(crate) use tools::ToolsState;
trait ErasedWorldStateSection: Send + Sync {
fn snapshot(&self) -> Option<Value>;
@@ -53,6 +55,9 @@ trait ErasedWorldStateSection: Send + Sync {
impl<S: WorldStateSection> ErasedWorldStateSection for S {
fn snapshot(&self) -> Option<Value> {
if !WorldStateSection::should_persist(self) {
return None;
}
let mut snapshot = match serde_json::to_value(WorldStateSection::snapshot(self)) {
Ok(snapshot) => snapshot,
Err(err) => {
@@ -197,6 +202,11 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
fn snapshot(&self) -> Self::Snapshot;
/// Whether the section contributes comparison state to persisted rollouts.
fn should_persist(&self) -> bool {
true
}
fn matches_legacy_fragment(_role: &str, _text: &str) -> bool {
false
}
@@ -0,0 +1,164 @@
use super::PreviousSectionState;
use super::WorldStateContextFragment;
use super::WorldStateSection;
use crate::context::ContextualUserFragment;
use crate::context::environment_context::push_xml_escaped_text;
use codex_extension_api::RenderedWorldStateFragment;
use codex_protocol::protocol::TOOLS_CLOSE_TAG;
use codex_protocol::protocol::TOOLS_OPEN_TAG;
use std::collections::BTreeMap;
const MAX_RENDERED_FRAGMENT_BYTES: usize = 4 * 1024;
const MAX_NAMESPACE_DESCRIPTION_CHARS: usize = 250;
const OMITTED_LINE_RESERVE_BYTES: usize = 64;
/// Deferred tool namespaces visible to the model for one sampling step.
#[derive(Debug, Default)]
pub(crate) struct ToolsState {
deferred_namespaces: BTreeMap<String, String>,
}
impl ToolsState {
pub(crate) fn new(deferred_namespaces: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
deferred_namespaces: deferred_namespaces
.into_iter()
.map(|(namespace, description)| {
let description = description
.lines()
.next()
.unwrap_or_default()
.trim()
.chars()
.take(MAX_NAMESPACE_DESCRIPTION_CHARS)
.collect();
(namespace, description)
})
.collect(),
}
}
}
impl WorldStateSection for ToolsState {
const ID: &'static str = "tools";
// Object-valued entries let RFC 7386 patches add and remove namespaces individually.
type Snapshot = BTreeMap<String, String>;
fn snapshot(&self) -> Self::Snapshot {
self.deferred_namespaces.clone()
}
fn should_persist(&self) -> bool {
!self.deferred_namespaces.is_empty()
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
let current = self.snapshot();
if matches!(previous, PreviousSectionState::Known(previous) if previous == &current)
|| self.deferred_namespaces.is_empty()
&& matches!(
previous,
PreviousSectionState::Absent | PreviousSectionState::Unknown
)
{
return None;
}
let body = match previous {
PreviousSectionState::Absent | PreviousSectionState::Unknown => {
render_namespace_groups(
&[("Deferred tool namespaces", &self.deferred_namespaces)],
self.deferred_namespaces.is_empty(),
)
}
PreviousSectionState::Known(previous) => {
let added = self
.deferred_namespaces
.iter()
.filter(|(namespace, description)| {
previous.get(*namespace) != Some(*description)
})
.map(|(namespace, description)| (namespace.clone(), description.clone()))
.collect();
let removed = previous
.iter()
.filter(|(namespace, _)| !self.deferred_namespaces.contains_key(*namespace))
.map(|(namespace, description)| (namespace.clone(), description.clone()))
.collect();
render_namespace_groups(
&[
("Added deferred tool namespaces", &added),
("Removed deferred tool namespaces", &removed),
],
self.deferred_namespaces.is_empty(),
)
}
};
Some(Box::new(WorldStateContextFragment(
RenderedWorldStateFragment::new("developer", (TOOLS_OPEN_TAG, TOOLS_CLOSE_TAG), body),
)))
}
}
fn render_namespace_groups(
groups: &[(&'static str, &BTreeMap<String, String>)],
current_is_empty: bool,
) -> String {
let body_budget =
MAX_RENDERED_FRAGMENT_BYTES.saturating_sub(TOOLS_OPEN_TAG.len() + TOOLS_CLOSE_TAG.len());
let empty_state = current_is_empty.then_some("No deferred tool namespaces remain.\n");
let fixed_bytes = 1
+ groups
.iter()
.filter(|(_, namespaces)| !namespaces.is_empty())
.map(|(label, _)| label.len() + ":\n".len() + OMITTED_LINE_RESERVE_BYTES)
.sum::<usize>()
+ empty_state.map_or(0, str::len);
let mut remaining_entry_bytes = body_budget.saturating_sub(fixed_bytes);
let mut rendered = "\n".to_string();
for (label, namespaces) in groups {
if namespaces.is_empty() {
continue;
}
rendered.push_str(label);
rendered.push_str(":\n");
let mut omitted = 0usize;
for (namespace, description) in *namespaces {
let entry = rendered_namespace(namespace, description);
if entry.len() <= remaining_entry_bytes {
remaining_entry_bytes -= entry.len();
rendered.push_str(&entry);
} else {
omitted += 1;
}
}
if omitted > 0 {
rendered.push_str("... ");
rendered.push_str(&omitted.to_string());
rendered.push_str(" additional namespaces omitted.\n");
}
}
if let Some(empty_state) = empty_state {
rendered.push_str(empty_state);
}
rendered
}
fn rendered_namespace(namespace: &str, description: &str) -> String {
let mut rendered = "- ".to_string();
push_xml_escaped_text(&mut rendered, namespace);
if !description.is_empty() {
rendered.push_str(": ");
push_xml_escaped_text(&mut rendered, description);
}
rendered.push('\n');
rendered
}
#[cfg(test)]
#[path = "tools_tests.rs"]
mod tests;
@@ -0,0 +1,92 @@
use super::MAX_NAMESPACE_DESCRIPTION_CHARS;
use super::MAX_RENDERED_FRAGMENT_BYTES;
use super::ToolsState;
use crate::context::world_state::PreviousSectionState;
use crate::context::world_state::WorldStateSection;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn renders_first_line_of_namespace_descriptions() {
let tools = ToolsState::new([
(
"app".to_string(),
" control the Codex App \nAdditional instructions.".to_string(),
),
(
"gmail".to_string(),
"access your Google Gmail Account & labels".to_string(),
),
("hotline".to_string(), String::new()),
]);
let rendered = tools
.render_diff(PreviousSectionState::Absent)
.expect("tools state should render")
.render();
assert_eq!(
rendered,
"<tools>\nDeferred tool namespaces:\n- app: control the Codex App\n- gmail: access your Google Gmail Account &amp; labels\n- hotline\n</tools>"
);
}
#[test]
fn renders_added_removed_and_updated_namespace_descriptions() {
let tools = ToolsState::new([
("app".to_string(), "control the Codex App".to_string()),
(
"gmail".to_string(),
"access your Google Gmail Account".to_string(),
),
]);
let previous = BTreeMap::from([
("gmail".to_string(), "old Gmail description".to_string()),
(
"hotline".to_string(),
"access hotline information".to_string(),
),
]);
let rendered = tools
.render_diff(PreviousSectionState::Known(&previous))
.expect("tools state delta should render")
.render();
assert_eq!(
rendered,
"<tools>\nAdded deferred tool namespaces:\n- app: control the Codex App\n- gmail: access your Google Gmail Account\nRemoved deferred tool namespaces:\n- hotline: access hotline information\n</tools>"
);
}
#[test]
fn caps_namespace_descriptions_by_character_count() {
let description = "🦀".repeat(MAX_NAMESPACE_DESCRIPTION_CHARS + 1);
let tools = ToolsState::new([("app".to_string(), description)]);
assert_eq!(
tools.snapshot(),
BTreeMap::from([(
"app".to_string(),
"🦀".repeat(MAX_NAMESPACE_DESCRIPTION_CHARS)
)])
);
}
#[test]
fn caps_rendered_tools_fragment_after_xml_escaping() {
let tools = ToolsState::new((0..100).map(|index| {
(
format!("namespace_{index}"),
"&".repeat(MAX_NAMESPACE_DESCRIPTION_CHARS),
)
}));
let rendered = tools
.render_diff(PreviousSectionState::Absent)
.expect("tools state should render")
.render();
assert!(rendered.len() <= MAX_RENDERED_FRAGMENT_BYTES);
assert!(rendered.contains(" additional namespaces omitted.\n"));
}
+2
View File
@@ -27,6 +27,7 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::TOOLS_OPEN_TAG;
use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
@@ -46,6 +47,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
PLUGINS_INSTRUCTIONS_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
TOOLS_OPEN_TAG,
"<personality_spec>",
// Keep recognizing token-budget wrappers persisted by older versions.
"<token_budget>",
+10
View File
@@ -11,6 +11,7 @@ use crate::context::world_state::MultiAgentModeState;
use crate::context::world_state::PermissionsState;
use crate::context::world_state::PluginsInstructionsState;
use crate::context::world_state::RealtimeState;
use crate::context::world_state::ToolsState;
use crate::context::world_state::WorldState;
use codex_extension_api::WorldStateContributionInput;
use codex_features::Feature;
@@ -105,6 +106,15 @@ impl Session {
world_state.add_section(PluginsInstructionsState::new(
step_context.mcp.plugins_available(),
));
if turn_context
.config
.features
.enabled(Feature::DeferredToolWorldState)
{
world_state.add_section(ToolsState::new(
step_context.tool_router.deferred_tool_namespaces(),
));
}
let environments = step_context.environments.to_selections();
let ready_selected_capability_roots = step_context
.selected_capability_roots
+28
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -345,6 +346,33 @@ impl ToolRegistry {
Self::new(tools_by_name)
}
pub(crate) fn deferred_tool_namespaces(&self) -> BTreeMap<String, String> {
let mut namespaces = BTreeMap::<String, String>::new();
for (name, tool) in &self.tools {
if tool.exposure() != ToolExposure::Deferred {
continue;
}
let Some(namespace) = &name.namespace else {
continue;
};
let existing_description = namespaces.entry(namespace.clone()).or_default();
if !existing_description.trim().is_empty() {
continue;
}
let description = match tool.spec() {
ToolSpec::Namespace(namespace) => namespace.description,
ToolSpec::Function(_)
| ToolSpec::Freeform(_)
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => String::new(),
};
if !description.trim().is_empty() {
*existing_description = description;
}
}
namespaces
}
#[cfg(test)]
pub(crate) fn empty_for_test() -> Self {
Self::new(HashMap::new())
+5
View File
@@ -20,6 +20,7 @@ use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio_util::sync::CancellationToken;
@@ -86,6 +87,10 @@ impl ToolRouter {
self.model_visible_specs.clone()
}
pub(crate) fn deferred_tool_namespaces(&self) -> BTreeMap<String, String> {
self.registry.deferred_tool_namespaces()
}
#[cfg(test)]
pub(crate) fn registered_tool_names_for_test(&self) -> Vec<ToolName> {
self.registry.tool_names_for_test()
+6
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::config::Config;
@@ -338,6 +339,11 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
namespace_function_names(&router.model_visible_specs(), "codex_app"),
vec![visible_tool.to_string()]
);
assert_eq!(
router.deferred_tool_namespaces(),
BTreeMap::from([("codex_app".to_string(), "Codex app tools.".to_string())])
);
Ok(())
}
+35 -1
View File
@@ -9,6 +9,7 @@ use crate::responses::strip_response_item_ids_from_json;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::TOOLS_OPEN_TAG;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ContextSnapshotRenderMode {
@@ -151,7 +152,10 @@ pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshot
let parts = rendered_parts
.iter()
.enumerate()
.map(|(part_idx, part)| format!(" [{:02}] {part}", part_idx + 1))
.map(|(part_idx, part)| {
let part = part.replace('\n', "\n ");
format!(" [{:02}] {part}", part_idx + 1)
})
.collect::<Vec<String>>()
.join("\n");
format!("{idx:02}:message/{role}:\n{parts}")
@@ -356,6 +360,10 @@ fn format_changed_lines_diff(
}
fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String {
if text.starts_with(TOOLS_OPEN_TAG) {
return normalize_snapshot_line_endings(&canonicalize_snapshot_text(text));
}
match options.render_mode {
ContextSnapshotRenderMode::RedactedText => {
normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)).replace('\n', "\\n")
@@ -602,6 +610,32 @@ mod tests {
assert_eq!(rendered, "00:message/developer:<PERMISSIONS_INSTRUCTIONS>");
}
#[test]
fn tools_context_uses_readable_multiline_snapshot_text() {
let items = vec![json!({
"type": "message",
"role": "developer",
"content": [
{ "type": "input_text", "text": "<permissions instructions>...</permissions instructions>" },
{
"type": "input_text",
"text": "<tools>\nDeferred tool namespaces:\n- multi_agent_v1: Tools for spawning and managing sub-agents.\n</tools>"
}
]
})];
let rendered = format_response_items_snapshot(
&items,
&ContextSnapshotOptions::default()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 32 }),
);
assert_eq!(
rendered,
"00:message/developer[2]:\n [01] <PERMISSIONS_INSTRUCTIONS>\n [02] <tools>\n Deferred tool namespaces:\n - multi_agent_v1: Tools for spawning and managing sub-agents.\n </tools>"
);
}
#[test]
fn strip_agents_md_user_context_omits_agents_fragment_from_user_messages() {
let items = vec![json!({
+312 -4
View File
@@ -12,15 +12,20 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::apps_enabled_builder;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
use core_test_support::responses;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -31,6 +36,7 @@ use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde_json::Value;
use serde_json::json;
@@ -43,6 +49,47 @@ struct McpResourceClientCapture {
client: Arc<Mutex<Option<McpResourceClient>>>,
}
fn format_labeled_requests_snapshot(
scenario: &str,
sections: &[(&str, &ResponsesRequest)],
) -> String {
context_snapshot::format_labeled_requests_snapshot(
scenario,
sections,
&ContextSnapshotOptions::default()
.strip_capability_instructions()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }),
)
}
fn enable_deferred_tool_world_state_without_agents(config: &mut Config) {
config.agents_enabled = false;
config
.features
.enable(Feature::DeferredToolWorldState)
.expect("test config should allow feature update");
}
fn tools_state_sections(request: &ResponsesRequest) -> Vec<String> {
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with("<tools>"))
.collect()
}
fn completed_response_sequence(count: usize) -> Vec<String> {
(1..=count)
.map(|index| {
sse(vec![
ev_response_created(&format!("resp-{index}")),
ev_assistant_message(&format!("msg-{index}"), "done"),
ev_completed(&format!("resp-{index}")),
])
})
.collect()
}
impl ThreadLifecycleContributor<Config> for McpResourceClientCapture {
fn on_thread_start<'a>(
&'a self,
@@ -183,7 +230,216 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()>
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apps_guidance_appears_after_recovery_between_sampling_requests() -> Result<()> {
async fn deferred_tool_world_state_is_disabled_by_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount_searchable(&server).await?;
let response = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone());
let test = builder.build(&server).await?;
test.submit_turn("inspect deferred MCP tools").await?;
let request = response.single_request();
assert!(
request
.message_input_texts("developer")
.into_iter()
.all(|text| !text.contains("<tools>")),
"deferred tool world state should not be injected unless its feature is enabled"
);
assert!(
request.body_json()["tools"]
.as_array()
.is_some_and(|tools| {
tools
.iter()
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("tool_search"))
}),
"disabling tool world state must not disable deferred tool discovery"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deferred_tool_world_state_tracks_initial_unchanged_and_removed_namespaces() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount_searchable(&server).await?;
let response = mount_sse_sequence(&server, completed_response_sequence(/*count*/ 3)).await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(enable_deferred_tool_world_state_without_agents);
let test = builder.build_with_auto_env(&server).await?;
wait_for_mcp_server(&test.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
test.submit_turn("inspect initially available deferred tools")
.await?;
test.submit_turn("inspect unchanged deferred tools").await?;
let mut refresh_config = test.config.clone();
let user_config_path = refresh_config.codex_home.join("config.toml");
let user_config = toml::from_str(
r#"
[apps.calendar]
enabled = false
"#,
)?;
refresh_config.config_layer_stack = refresh_config
.config_layer_stack
.with_user_config(&user_config_path, user_config)?;
test.codex.refresh_runtime_config(refresh_config).await;
test.codex.submit(Op::RefreshMcpServers).await?;
test.submit_turn("inspect removed deferred tools").await?;
let requests = response.requests();
assert_eq!(requests.len(), 3);
let tools_states = requests
.iter()
.map(tools_state_sections)
.collect::<Vec<_>>();
assert_eq!(tools_states[0], tools_states[1]);
assert_eq!(tools_states[2].len(), 2);
assert!(tools_states[2][1].contains("Removed deferred tool namespaces:\n"));
assert!(tools_states[2][1].contains("No deferred tool namespaces remain.\n"));
insta::assert_snapshot!(
"deferred_tools_initial_unchanged_and_removed",
format_labeled_requests_snapshot(
"Initially available deferred tools remain unchanged until disabling Calendar removes them.",
&[
("Initially available", &requests[0]),
("Unchanged follow-up", &requests[1]),
("Removed and empty", &requests[2]),
],
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn initially_empty_deferred_tool_world_state_is_not_rendered_or_persisted() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = mount_sse_sequence(&server, completed_response_sequence(/*count*/ 1)).await;
let mut builder = core_test_support::test_codex::test_codex()
.with_config(enable_deferred_tool_world_state_without_agents);
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("inspect empty deferred tools").await?;
let request = response.single_request();
assert!(tools_state_sections(&request).is_empty());
test.codex.ensure_rollout_materialized().await;
test.codex.flush_rollout().await?;
let rollout_path = test.codex.rollout_path().expect("rollout path");
let world_states = tokio::fs::read_to_string(rollout_path)
.await?
.lines()
.map(serde_json::from_str::<RolloutLine>)
.collect::<serde_json::Result<Vec<_>>>()?
.into_iter()
.filter_map(|line| match line.item {
RolloutItem::WorldState(item) => Some(item.state),
_ => None,
})
.collect::<Vec<_>>();
assert!(!world_states.is_empty());
assert!(
world_states
.iter()
.all(|state| state.get("tools").is_none())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deferred_tool_world_state_survives_resume_without_duplicate_updates() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount_searchable(&server).await?;
let response = mount_sse_sequence(&server, completed_response_sequence(/*count*/ 2)).await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(enable_deferred_tool_world_state_without_agents);
let initial = builder.build_with_auto_env(&server).await?;
wait_for_mcp_server(&initial.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
initial
.submit_turn("inspect deferred tools before resume")
.await?;
initial.codex.ensure_rollout_materialized().await;
initial.codex.flush_rollout().await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let persisted_tools = tokio::fs::read_to_string(&rollout_path)
.await?
.lines()
.map(serde_json::from_str::<RolloutLine>)
.collect::<serde_json::Result<Vec<_>>>()?
.into_iter()
.filter_map(|line| match line.item {
RolloutItem::WorldState(item) => item.state.get("tools").cloned(),
_ => None,
})
.next_back()
.expect("rollout should persist the deferred tools world state");
assert_eq!(
persisted_tools,
json!({
"mcp__codex_apps__calendar": "Plan events and manage your calendar."
})
);
drop(initial);
let mut resume_builder = search_capable_apps_builder(apps_server.chatgpt_base_url)
.with_config(enable_deferred_tool_world_state_without_agents);
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
wait_for_mcp_server(&resumed.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
resumed
.submit_turn("inspect unchanged deferred tools after resume")
.await?;
let requests = response.requests();
assert_eq!(requests.len(), 2);
let tools_states = requests
.iter()
.map(tools_state_sections)
.collect::<Vec<_>>();
assert_eq!(tools_states[0], tools_states[1]);
assert_eq!(tools_states[0].len(), 1);
assert!(tools_states[0][0].contains(SEARCH_CALENDAR_NAMESPACE));
insta::assert_snapshot!(
"deferred_tools_resume_without_duplicate_update",
format_labeled_requests_snapshot(
"Persisted deferred tools remain unchanged after resuming the thread.",
&[
("Before resume", &requests[0]),
("After resume", &requests[1]),
],
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apps_guidance_and_deferred_namespace_appear_after_recovery_within_a_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -226,12 +482,16 @@ async fn apps_guidance_appears_after_recovery_between_sampling_requests() -> Res
],
)
.await;
let mut builder =
apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| {
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(|config| {
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("test config should allow feature update");
config
.features
.enable(Feature::DeferredToolWorldState)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
@@ -286,6 +546,15 @@ async fn apps_guidance_appears_after_recovery_between_sampling_requests() -> Res
.count(),
0
);
let initial_tools_state = initial_request
.message_input_texts("developer")
.into_iter()
.find(|text| text.contains("<tools>"))
.expect("initial request should contain tools world state");
assert!(
!initial_tools_state.contains(SEARCH_CALENDAR_NAMESPACE),
"Calendar namespace should not be advertised before recovery: {initial_tools_state}"
);
test.codex
.submit(Op::UserInputAnswer {
@@ -315,6 +584,45 @@ async fn apps_guidance_appears_after_recovery_between_sampling_requests() -> Res
.count(),
1
);
let recovered_tools_state = requests[1]
.message_input_texts("developer")
.into_iter()
.find(|text| text.contains("Added deferred tool namespaces:"))
.expect("recovered request should contain a tools world-state delta");
assert!(
recovered_tools_state.contains(&format!(
"- {SEARCH_CALENDAR_NAMESPACE}: Plan events and manage your calendar."
)),
"Calendar namespace and description should be added after recovery: {recovered_tools_state}"
);
let recovered_body = requests[1].body_json();
assert!(
namespace_child_tool(
&recovered_body,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
)
.is_none(),
"deferred Calendar namespace should not be directly advertised: {recovered_body}"
);
assert!(
recovered_body["tools"].as_array().is_some_and(|tools| {
tools
.iter()
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("tool_search"))
}),
"recovered request should advertise tool_search: {recovered_body}"
);
insta::assert_snapshot!(
"deferred_tools_recover_during_sampling",
format_labeled_requests_snapshot(
"Deferred namespaces appear after Apps recovers between sampling requests.",
&[
("Apps unavailable", &requests[0]),
("Apps recovered", &requests[1]),
],
)
);
Ok(())
}
@@ -0,0 +1,46 @@
---
source: core/tests/suite/mcp_tool_exposure.rs
expression: "format_labeled_requests_snapshot(\"Initially available deferred tools remain unchanged until disabling Calendar removes them.\",\n&[(\"Initially available\", &requests[0]),\n(\"Unchanged follow-up\", &requests[1]), (\"Removed and empty\", &requests[2]),],)"
---
Scenario: Initially available deferred tools remain unchanged until disabling Calendar removes them.
## Initially available
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:inspect initially available deferred tools
## Unchanged follow-up
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:inspect initially available deferred tools
03:message/assistant:done
04:message/user:inspect unchanged deferred tools
## Removed and empty
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:inspect initially available deferred tools
03:message/assistant:done
04:message/user:inspect unchanged deferred tools
05:message/assistant:done
06:message/developer:<tools>
Removed deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
No deferred tool namespaces remain.
</tools>
07:message/user:inspect removed deferred tools
@@ -0,0 +1,31 @@
---
source: core/tests/suite/mcp_tool_exposure.rs
expression: "format_labeled_requests_snapshot(\"Deferred namespaces appear after Apps recovers between sampling requests.\",\n&[(\"Apps unavailable\", &requests[0]), (\"Apps recovered\", &requests[1]),],)"
---
Scenario: Deferred namespaces appear after Apps recovers between sampling requests.
## Apps unavailable
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- multi_agent_v1: Tools for spawning and managing sub-agents.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:use an app after it recovers
## Apps recovered
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- multi_agent_v1: Tools for spawning and managing sub-agents.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:use an app after it recovers
03:function_call/request_user_input
04:function_call_output:{"answers":{"continue":{"answers":["Yes (Recommended)"]}}}
05:message/developer:<tools>
Added deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
@@ -0,0 +1,28 @@
---
source: core/tests/suite/mcp_tool_exposure.rs
expression: "format_labeled_requests_snapshot(\"Persisted deferred tools remain unchanged after resuming the thread.\",\n&[(\"Before resume\", &requests[0]), (\"After resume\", &requests[1]),],)"
---
Scenario: Persisted deferred tools remain unchanged after resuming the thread.
## Before resume
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:inspect deferred tools before resume
## After resume
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <tools>
Deferred tool namespaces:
- mcp__codex_apps__calendar: Plan events and manage your calendar.
</tools>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:inspect deferred tools before resume
03:message/assistant:done
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:inspect unchanged deferred tools after resume
+8
View File
@@ -169,6 +169,8 @@ pub enum Feature {
ToolSearch,
/// Removed compatibility flag. MCP tools are always deferred when tool_search is available.
ToolSearchAlwaysDeferMcpTools,
/// Describe deferred tool namespaces in the model-visible world state.
DeferredToolWorldState,
/// Expose MCP model-visible namespaces without the legacy `mcp__` prefix.
NonPrefixedMcpToolNames,
/// Enable discoverable tool suggestions for apps.
@@ -1122,6 +1124,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: true,
},
FeatureSpec {
id: Feature::DeferredToolWorldState,
key: "deferred_tool_world_state",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::NonPrefixedMcpToolNames,
key: "non_prefixed_mcp_tool_names",
+2
View File
@@ -111,6 +111,8 @@ pub const SKILLS_INSTRUCTIONS_OPEN_TAG: &str = "<skills_instructions>";
pub const SKILLS_INSTRUCTIONS_CLOSE_TAG: &str = "</skills_instructions>";
pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = "<plugins_instructions>";
pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = "</plugins_instructions>";
pub const TOOLS_OPEN_TAG: &str = "<tools>";
pub const TOOLS_CLOSE_TAG: &str = "</tools>";
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
pub const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";