mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Clarify incremental tool namespace updates in Responses Lite (#51119)
## Why Incremental namespace declarations contain only changed or added tools. Their descriptions need to make clear that previously declared tools remain available unless explicitly marked unavailable, and that redefinitions replace earlier definitions. ## What changed Append this guidance to updates of existing namespaces, preserving any namespace description. Leave initial declarations and catalog hashes unchanged. ## Testing Extend unit and request-history coverage for the namespace hint, unchanged turns that append no tool definitions, and MCP tool removals that append a standalone notice without rewriting history or repeating the notice on the next turn. GitOrigin-RevId: e32f3c9948efb44a1253868fe67967f117cc21e8
This commit is contained in:
+1
-1
@@ -68,7 +68,7 @@ None
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "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.",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{
|
||||
|
||||
@@ -1,5 +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.
|
||||
|
||||
use super::PreviousSectionState;
|
||||
use super::SectionTransition;
|
||||
@@ -15,6 +16,8 @@ use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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.";
|
||||
|
||||
/// The exact serialized Responses Lite declarations visible in one captured step.
|
||||
pub(crate) struct TopLevelToolsState {
|
||||
definitions: Vec<Value>,
|
||||
@@ -100,6 +103,14 @@ impl WorldStateSection for TopLevelToolsState {
|
||||
if !members.is_empty() {
|
||||
let mut namespace = definition.clone();
|
||||
namespace["tools"] = Value::Array(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
|
||||
|
||||
@@ -96,6 +96,11 @@ fn initial_catalog_preserves_order_and_unchanged_catalog_emits_nothing() {
|
||||
];
|
||||
let state = TopLevelToolsState::new(definitions.clone()).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)]);
|
||||
let snapshot = snapshot.unwrap();
|
||||
assert_eq!(
|
||||
@@ -159,10 +164,12 @@ 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}"));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![
|
||||
item(vec![namespace("functions", vec![changed, added])]),
|
||||
item(vec![expected]),
|
||||
ContextualUserFragment::into(RemovedTools(vec!["functions.removed".to_string()])),
|
||||
]
|
||||
);
|
||||
@@ -213,9 +220,10 @@ 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 updated = namespace("functions", vec![custom]);
|
||||
let mut updated = namespace("functions", vec![custom]);
|
||||
let current = TopLevelToolsState::new(vec![updated.clone()]).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![
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Request-history coverage for opt-in incremental tools on Responses Lite.
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::ConfigRefreshOutcome;
|
||||
use codex_features::Feature;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_protocol::openai_models::CodeModeToolMessages;
|
||||
@@ -11,11 +12,16 @@ use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_wine_exec;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
|
||||
use super::super::rmcp_client::remote_aware_environment_id;
|
||||
use super::super::rmcp_client::remote_aware_stdio_server_bin;
|
||||
|
||||
#[test_case(true; "responses_lite")]
|
||||
#[test_case(false; "responses_api")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -26,7 +32,7 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
(1..=3)
|
||||
(1..=4)
|
||||
.map(|index| responses::sse(vec![responses::ev_completed(&format!("resp-{index}"))]))
|
||||
.collect(),
|
||||
)
|
||||
@@ -80,9 +86,11 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
.await?;
|
||||
test.submit_text_turn("Continue with the updated execution instructions.")
|
||||
.await?;
|
||||
test.submit_text_turn("Continue with the same updated tools.")
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(requests.len(), 4);
|
||||
if !use_responses_lite {
|
||||
for request in &requests {
|
||||
let body = request.body_json();
|
||||
@@ -104,10 +112,15 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
requests[1].body_json()["tools"],
|
||||
requests[2].body_json()["tools"]
|
||||
);
|
||||
assert_eq!(
|
||||
requests[2].body_json()["tools"],
|
||||
requests[3].body_json()["tools"]
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
assert!(requests[1].input().starts_with(&requests[0].input()));
|
||||
assert!(requests[2].input().starts_with(&requests[1].input()));
|
||||
assert!(requests[3].input().starts_with(&requests[2].input()));
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
@@ -134,6 +147,13 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
.expect("changed tool array");
|
||||
assert_eq!(diff.len(), 1);
|
||||
assert_eq!(diff[0]["name"], "functions");
|
||||
assert_eq!(initial[0]["tools"][0]["description"], json!(""));
|
||||
assert_eq!(
|
||||
diff[0]["description"],
|
||||
json!(
|
||||
"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."
|
||||
)
|
||||
);
|
||||
let changed_tools = diff[0]["tools"].as_array().expect("namespace members");
|
||||
assert_eq!(changed_tools.len(), 1);
|
||||
assert_eq!(changed_tools[0]["name"], "exec");
|
||||
@@ -144,6 +164,7 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
.to_string()
|
||||
.contains("Updated execution instructions.")
|
||||
);
|
||||
assert_eq!(requests[3].inputs_of_type("additional_tools"), changed);
|
||||
|
||||
test.codex.ensure_rollout_materialized().await;
|
||||
test.codex.flush_rollout().await?;
|
||||
@@ -181,7 +202,135 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
insta::assert_snapshot!(
|
||||
"incremental_tools",
|
||||
context_snapshot::format_request_history_snapshot(
|
||||
"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition.",
|
||||
"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default()
|
||||
.rewrite_known_segments()
|
||||
.include_request_settings(),
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() -> Result<()> {
|
||||
skip_if_wine_exec!(
|
||||
Ok(()),
|
||||
"requires a Windows test_stdio_server in the Wine-exec environment"
|
||||
);
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = responses::start_mock_server().await;
|
||||
let command = remote_aware_stdio_server_bin()?;
|
||||
let environment_id = remote_aware_environment_id();
|
||||
let server_config = |enabled_tools: &[&str]| {
|
||||
serde_json::from_value(json!({
|
||||
"command": command,
|
||||
"environment_id": environment_id,
|
||||
"env": {"MCP_TEST_SERVER_INSTRUCTIONS": "Echo service instructions."},
|
||||
"enabled_tools": enabled_tools,
|
||||
"default_tools_approval_mode": "approve",
|
||||
}))
|
||||
};
|
||||
let initial_server = server_config(&["echo", "cwd"])?;
|
||||
let reduced_server = server_config(&["echo"])?;
|
||||
let test = test_codex()
|
||||
.with_model_info_override("gpt-5.5", |model| {
|
||||
model.use_responses_lite = true;
|
||||
model.supports_search_tool = false;
|
||||
model.tool_mode = Some(ToolMode::Direct);
|
||||
})
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::IncrementalTools)
|
||||
.expect("enable incremental tools");
|
||||
config
|
||||
.mcp_servers
|
||||
.set(std::collections::HashMap::from([(
|
||||
"echo_service".to_string(),
|
||||
initial_server,
|
||||
)]))
|
||||
.expect("set MCP fixture");
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, "echo_service").await?;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
(1..=3)
|
||||
.map(|index| responses::sse(vec![responses::ev_completed(&format!("resp-{index}"))]))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
test.submit_text_turn("Begin with the echo service.")
|
||||
.await?;
|
||||
|
||||
// Remove one member while keeping the namespace instructions and echo schema unchanged.
|
||||
let current_config = test.codex.config().await;
|
||||
let mut reduced_config = current_config.as_ref().clone();
|
||||
reduced_config
|
||||
.mcp_servers
|
||||
.set(std::collections::HashMap::from([(
|
||||
"echo_service".to_string(),
|
||||
reduced_server,
|
||||
)]))?;
|
||||
assert_eq!(
|
||||
test.codex
|
||||
.refresh_mcp_config(current_config, reduced_config)
|
||||
.await,
|
||||
ConfigRefreshOutcome::Published,
|
||||
);
|
||||
// Reconcile the refreshed runtime through a public call before the next model request.
|
||||
let result = test
|
||||
.codex
|
||||
.call_mcp_tool(
|
||||
"echo_service",
|
||||
"echo",
|
||||
Some(json!({"message": "ready after removal"})),
|
||||
/*meta*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_ne!(result.is_error, Some(true));
|
||||
test.submit_text_turn("Continue after disabling the cwd tool.")
|
||||
.await?;
|
||||
test.submit_text_turn("Continue with the remaining echo tool.")
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert!(requests[1].has_content_kinds(&["tools.removed_definition"]));
|
||||
let initial_tools = requests[0].inputs_of_type("additional_tools");
|
||||
for pair in requests.windows(2) {
|
||||
assert!(pair[1].input().starts_with(&pair[0].input()));
|
||||
assert_eq!(pair[1].inputs_of_type("additional_tools"), initial_tools);
|
||||
}
|
||||
let removed_input = requests[1].input();
|
||||
let updates = &removed_input[requests[0].input().len()..];
|
||||
assert_eq!(
|
||||
updates.len(),
|
||||
2,
|
||||
"removal appends a standalone notice and user input"
|
||||
);
|
||||
assert_eq!(updates[0]["role"], "developer");
|
||||
assert_eq!(
|
||||
updates[0]["content"],
|
||||
json!([{
|
||||
"type": "input_text",
|
||||
"text": "The following tools are no longer available. Do not call them:\n- mcp__echo_service.cwd",
|
||||
}])
|
||||
);
|
||||
let unchanged_input = requests[2].input();
|
||||
let follow_up = &unchanged_input[removed_input.len()..];
|
||||
assert_eq!(follow_up.len(), 1, "unchanged turn appends only user input");
|
||||
assert_eq!(follow_up[0]["role"], "user");
|
||||
assert_eq!(
|
||||
follow_up[0]["content"],
|
||||
json!([{"type": "input_text", "text": "Continue with the remaining echo tool."}])
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"incremental_tool_removals",
|
||||
context_snapshot::format_request_history_snapshot(
|
||||
"An MCP configuration refresh removes one tool while preserving its namespace and other member. A standalone removal notice preserves earlier declarations and is not repeated on the next turn.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default()
|
||||
.rewrite_known_segments()
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_incremental_tools.rs
|
||||
expression: "context_snapshot::format_request_history_snapshot(\"An MCP configuration refresh removes one tool while preserving its namespace and other member. A standalone removal notice preserves earlier declarations and is not repeated on the next turn.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
|
||||
---
|
||||
Scenario: An MCP configuration refresh removes one tool while preserving its namespace and other member. A standalone removal notice preserves earlier declarations and is not repeated on the next turn.
|
||||
|
||||
## Window 1
|
||||
Settings:
|
||||
include: ["reasoning.encrypted_content"]
|
||||
model: "gpt-5.5"
|
||||
parallel_tool_calls: false
|
||||
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
|
||||
reasoning: {"context":"all_turns","effort":"medium"}
|
||||
store: false
|
||||
stream: true
|
||||
text: {"verbosity":"low"}
|
||||
tool_choice: "auto"
|
||||
-- request 1 (turn) --
|
||||
00:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
01:additional_tools/developer (3; hash=7748a53142f449b7):
|
||||
- namespace/functions; hash=124fee69a69ed6b2
|
||||
- function/exec_command
|
||||
- function/write_stdin
|
||||
- function/list_mcp_resources
|
||||
- function/list_mcp_resource_templates
|
||||
- function/read_mcp_resource
|
||||
- function/request_user_input
|
||||
- custom/apply_patch
|
||||
- function/view_image
|
||||
- namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50
|
||||
- function/close_agent
|
||||
- function/resume_agent
|
||||
- function/send_input
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
- namespace/mcp__echo_service: Echo service instructions.; hash=a5144e5d70d01799
|
||||
- function/cwd
|
||||
- function/echo
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
04:message/user:
|
||||
Begin with the echo service.
|
||||
-- request 2 (turn) --
|
||||
05:message/developer:
|
||||
The following tools are no longer available. Do not call them:
|
||||
- mcp__echo_service.cwd
|
||||
06:message/user:
|
||||
Continue after disabling the cwd tool.
|
||||
-- request 3 (turn) --
|
||||
07:message/user:
|
||||
Continue with the remaining echo tool.
|
||||
+7
-4
@@ -1,8 +1,8 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_incremental_tools.rs
|
||||
expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
|
||||
expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
|
||||
---
|
||||
Scenario: Tool definitions enter history in one batch; a catalog change appends only the changed exec definition.
|
||||
Scenario: Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.
|
||||
|
||||
## Window 1
|
||||
Settings:
|
||||
@@ -62,10 +62,13 @@ Settings: relative to window 1
|
||||
Begin work.
|
||||
05:message/user:
|
||||
Continue with the same tools.
|
||||
06:additional_tools/developer (1; hash=26cd263a29587cc7):
|
||||
- namespace/functions; hash=771cad2ff250d2f3
|
||||
06:additional_tools/developer (1; hash=fd00dfa0a0c11da2):
|
||||
- namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=dd09638a0d29d0ca
|
||||
- custom/exec
|
||||
07:message/developer:
|
||||
<MODEL_SWITCH>
|
||||
08:message/user:
|
||||
Continue with the updated execution instructions.
|
||||
-- request 4 (turn) --
|
||||
09:message/user:
|
||||
Continue with the same updated tools.
|
||||
|
||||
Reference in New Issue
Block a user