mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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 == ¤t)
|
||||
|| 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 & 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"));
|
||||
}
|
||||
@@ -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>",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+46
@@ -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
|
||||
+31
@@ -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>
|
||||
+28
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>";
|
||||
|
||||
Reference in New Issue
Block a user