mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Send incremental tool catalog updates in Responses Lite (#50540)
## What changed When `IncrementalTools` is enabled for Responses Lite, track tool definitions in world state and conversation history. Send the initial catalog once, then append only added or changed definitions while preserving earlier request history. Emit developer notices for removed tools and namespace instruction changes. Use history-supplied definitions for normal requests and remote compaction, and omit empty `additional_tools` prefixes. Keep the existing tool delivery behavior for the Responses API and when the feature is disabled. ## Testing Add unit and snapshot coverage for catalog and namespace transitions, empty catalogs, and stable hashing across JSON object key order. Add a request-history scenario covering unchanged tools, a model switch that changes one definition, persisted catalog hashes, and Responses API behavior. GitOrigin-RevId: 76bde175973b81f8ad23873919da3c8ddcdf6065
This commit is contained in:
@@ -911,15 +911,18 @@ impl ModelClient {
|
||||
&Uuid::NAMESPACE_OID,
|
||||
self.state.thread_id.to_string().as_bytes(),
|
||||
);
|
||||
let tools = create_tools_json_for_responses_lite(&prompt.tools)?;
|
||||
let mut prefix = vec![ResponseItem::AdditionalTools {
|
||||
id: Some(ResponseItemId::with_suffix(
|
||||
"at",
|
||||
Uuid::new_v5(&prefix_namespace, &serde_json::to_vec(&tools)?),
|
||||
)),
|
||||
role: "developer".to_string(),
|
||||
tools,
|
||||
}];
|
||||
let mut prefix = Vec::new();
|
||||
if !prompt.tools.is_empty() {
|
||||
let tools = create_tools_json_for_responses_lite(&prompt.tools)?;
|
||||
prefix.push(ResponseItem::AdditionalTools {
|
||||
id: Some(ResponseItemId::with_suffix(
|
||||
"at",
|
||||
Uuid::new_v5(&prefix_namespace, &serde_json::to_vec(&tools)?),
|
||||
)),
|
||||
role: "developer".to_string(),
|
||||
tools,
|
||||
});
|
||||
}
|
||||
if !prompt.base_instructions.text.is_empty() {
|
||||
let mut instructions = ContextualUserFragment::into(BaseInstructionsFragment(
|
||||
prompt.base_instructions.text.clone(),
|
||||
|
||||
@@ -24,8 +24,7 @@ pub struct Prompt {
|
||||
/// Conversation context input items.
|
||||
pub input: Vec<ResponseItem>,
|
||||
|
||||
/// Tools available to the model, including additional tools sourced from
|
||||
/// external MCP servers.
|
||||
/// Tool definitions to inject into this request, empty when supplied by history.
|
||||
pub(crate) tools: Arc<[ToolSpec]>,
|
||||
|
||||
/// Whether parallel tool calls are permitted for this prompt.
|
||||
|
||||
@@ -1017,7 +1017,18 @@ fn responses_lite_prefix_ids_track_thread_and_payload() -> anyhow::Result<()> {
|
||||
let client = test_model_client_with_thread_id(thread_id, SessionSource::Cli);
|
||||
let mut model = test_model_info();
|
||||
model.use_responses_lite = true;
|
||||
let mut tool = codex_tools::FreeformTool {
|
||||
name: "exec".to_string(),
|
||||
description: "Execute JavaScript.".to_string(),
|
||||
defer_loading: None,
|
||||
format: codex_tools::FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: /.+/".to_string(),
|
||||
},
|
||||
};
|
||||
let mut prompt = Prompt {
|
||||
tools: vec![codex_tools::ToolSpec::Freeform(tool.clone())].into(),
|
||||
base_instructions: BaseInstructions {
|
||||
text: "base instructions".to_string(),
|
||||
provenance: None,
|
||||
@@ -1050,17 +1061,9 @@ fn responses_lite_prefix_ids_track_thread_and_payload() -> anyhow::Result<()> {
|
||||
assert_eq!(changed_instructions.input[0], original.input[0]);
|
||||
assert_ne!(changed_instructions.input[1].id(), original.input[1].id());
|
||||
|
||||
prompt.tools = vec![codex_tools::ToolSpec::Freeform(codex_tools::FreeformTool {
|
||||
name: "exec".to_string(),
|
||||
description: "Execute JavaScript.".to_string(),
|
||||
defer_loading: None,
|
||||
format: codex_tools::FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: /.+/".to_string(),
|
||||
},
|
||||
})]
|
||||
.into();
|
||||
tool.description
|
||||
.push_str(" Updated execution instructions.");
|
||||
prompt.tools = vec![codex_tools::ToolSpec::Freeform(tool)].into();
|
||||
let changed_tools = build(&client, &prompt)?;
|
||||
assert_ne!(
|
||||
changed_tools.input[0].id(),
|
||||
|
||||
@@ -83,7 +83,11 @@ pub(super) async fn run_remote_compact_v2_attempt(
|
||||
input.push(ResponseItem::CompactionTrigger {});
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
tools: if step_context.uses_incremental_tools() {
|
||||
Arc::default()
|
||||
} else {
|
||||
tool_router.model_visible_specs()
|
||||
},
|
||||
parallel_tool_calls: true,
|
||||
base_instructions,
|
||||
output_schema: None,
|
||||
|
||||
@@ -17,6 +17,7 @@ mod realtime;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
mod tools;
|
||||
mod top_level_tools;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_extension_api::PreviousWorldStateSection;
|
||||
@@ -55,6 +56,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::TopLevelToolsState;
|
||||
|
||||
/// One contribution to model context and its placement policy.
|
||||
pub(crate) struct WorldStateUpdate {
|
||||
@@ -65,10 +67,6 @@ pub(crate) struct WorldStateUpdate {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Placement {
|
||||
/// A separate item placed at the front when assembling initial context.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "Prefix producers are added separately")
|
||||
)]
|
||||
Prefix,
|
||||
Standalone,
|
||||
Mergeable,
|
||||
@@ -76,10 +74,6 @@ pub(crate) enum Placement {
|
||||
|
||||
pub(crate) enum WorldStateUpdateContent {
|
||||
Fragment(Box<dyn ContextualUserFragment>),
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "Item producers are added separately")
|
||||
)]
|
||||
Item(Box<ResponseItem>),
|
||||
}
|
||||
|
||||
@@ -115,6 +109,13 @@ impl WorldStateUpdate {
|
||||
self.placement = Placement::Standalone;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn prefix_item(item: ResponseItem) -> Self {
|
||||
Self {
|
||||
placement: Placement::Prefix,
|
||||
content: WorldStateUpdateContent::Item(Box::new(item)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type SectionTransition<S = Value> = (Option<S>, Vec<WorldStateUpdate>);
|
||||
@@ -338,12 +339,18 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
|
||||
) -> SectionTransition<Self::Snapshot>;
|
||||
}
|
||||
|
||||
/// Stable fingerprint of a model-visible World State fragment.
|
||||
/// Stable fingerprint of model-visible world-state content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub(crate) struct WorldStateHash(String);
|
||||
|
||||
impl WorldStateHash {
|
||||
pub(crate) fn from_json(value: &Value) -> Self {
|
||||
let mut value = value.clone();
|
||||
value.sort_all_objects();
|
||||
Self(format!("{:x}", Sha1::digest(value.to_string().as_bytes())))
|
||||
}
|
||||
|
||||
pub(crate) fn from_fragment(fragment: &(impl ContextualUserFragment + ?Sized)) -> Self {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(b"codex-world-state-fragment-v1\0");
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
---
|
||||
source: core/src/context/world_state/top_level_tools_tests.rs
|
||||
expression: "render_section_cases(&[(Absent, Known(&original)),\n(Known(&original), Known(&original)), (Known(&original), Known(&updated)),\n(Known(&updated), Known(&only_web)), (Known(&only_web), Known(&empty)),\n(Known(&empty), Known(&original)),])"
|
||||
---
|
||||
Absent -> {"tool_search":"bfee90bd93851aed12eecbb728658337d2da406d"} (item - prefix)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Search tools.",
|
||||
"execution": "client",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "tool_search"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
|
||||
{"tool_search":"bfee90bd93851aed12eecbb728658337d2da406d"} -> {"tool_search":"bfee90bd93851aed12eecbb728658337d2da406d"}
|
||||
None
|
||||
|
||||
{"tool_search":"bfee90bd93851aed12eecbb728658337d2da406d"} -> {"tool_search":"237fed8bc2eaaab94fe11b9a85520c82c1d347ba","web_search":"b1f849d856edd2e24392555fd84e602e78680451"} (item - prefix)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Search tools.",
|
||||
"execution": "client",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "tool_search"
|
||||
},
|
||||
{
|
||||
"external_web_access": true,
|
||||
"type": "web_search"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
|
||||
{"tool_search":"237fed8bc2eaaab94fe11b9a85520c82c1d347ba","web_search":"b1f849d856edd2e24392555fd84e602e78680451"} -> {"web_search":"b1f849d856edd2e24392555fd84e602e78680451"} (role - developer)
|
||||
The following tools are no longer available. Do not call them:
|
||||
- tool_search
|
||||
|
||||
{"web_search":"b1f849d856edd2e24392555fd84e602e78680451"} -> {} (role - developer)
|
||||
The following tools are no longer available. Do not call them:
|
||||
- web_search
|
||||
|
||||
{} -> {"tool_search":"bfee90bd93851aed12eecbb728658337d2da406d"} (item - prefix)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Search tools.",
|
||||
"execution": "client",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "tool_search"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
---
|
||||
source: core/src/context/world_state/top_level_tools_tests.rs
|
||||
expression: "render_section_cases(&[(Absent, Known(&original)),\n(Known(&original), Known(&original)), (Known(&original), Known(&updated)),\n(Known(&updated), Known(&only_other)),\n(Known(&only_other), Known(&original)),])"
|
||||
---
|
||||
Absent -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Tools.",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "unchanged",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "removed",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "changed",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"type": "namespace"
|
||||
},
|
||||
{
|
||||
"description": "Tools.",
|
||||
"name": "other",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"type": "namespace"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
|
||||
{"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"}
|
||||
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)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Tools.",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "changed",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "added",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"type": "namespace"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
(role - developer)
|
||||
The following tools are no longer available. Do not call them:
|
||||
- functions.removed
|
||||
|
||||
{"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.added":"5fc75ad4d08cd0b73ef983d58fed9272072e5ce7","functions.changed":"ebe9005f973a60ba4ee93b024d7021d21dc27826","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (role - developer)
|
||||
The following tools are no longer available. Do not call them:
|
||||
- functions
|
||||
- functions.added
|
||||
- functions.changed
|
||||
- functions.unchanged
|
||||
|
||||
{"other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
|
||||
{
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Tools.",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "unchanged",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "removed",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Look up a value.",
|
||||
"name": "changed",
|
||||
"parameters": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"type": "namespace"
|
||||
}
|
||||
],
|
||||
"type": "additional_tools"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::ErasedWorldStateSection;
|
||||
use super::Placement;
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldState;
|
||||
use super::WorldStateSection;
|
||||
@@ -14,15 +15,34 @@ pub(super) fn render_section_cases<'a, S: WorldStateSection>(
|
||||
cases
|
||||
.iter()
|
||||
.map(|(before, after)| {
|
||||
let rendered = render_diff(before, after);
|
||||
let role = rendered.as_ref().map_or_else(String::new, |fragment| {
|
||||
format!(" (role - {})", fragment.role())
|
||||
});
|
||||
let content = rendered
|
||||
.as_ref()
|
||||
.map_or_else(|| "None".to_string(), |fragment| fragment.render());
|
||||
let updates = render_diff(before, after);
|
||||
let rendered = if updates.is_empty() {
|
||||
"\nNone".to_string()
|
||||
} else {
|
||||
updates
|
||||
.into_iter()
|
||||
.map(|update| match update.content {
|
||||
WorldStateUpdateContent::Fragment(fragment) => {
|
||||
format!(" (role - {})\n{}", fragment.role(), fragment.render())
|
||||
}
|
||||
WorldStateUpdateContent::Item(item) => {
|
||||
let placement = match update.placement {
|
||||
Placement::Prefix => "prefix",
|
||||
Placement::Standalone => "standalone",
|
||||
Placement::Mergeable => "mergeable",
|
||||
};
|
||||
let value = serde_json::to_value(&item)
|
||||
.expect("world-state item should serialize");
|
||||
let content = serde_json::to_string_pretty(&sort_json(value))
|
||||
.expect("world-state item should serialize");
|
||||
format!(" (item - {placement})\n{content}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
format!(
|
||||
"{} -> {}{role}\n{content}",
|
||||
"{} -> {}{rendered}",
|
||||
render_state(before),
|
||||
render_state(after),
|
||||
)
|
||||
@@ -42,9 +62,9 @@ fn render_state<S: WorldStateSection>(state: &PreviousSectionState<'_, S>) -> St
|
||||
fn render_diff<S: WorldStateSection>(
|
||||
before: &PreviousSectionState<'_, S>,
|
||||
after: &PreviousSectionState<'_, S>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
) -> Vec<WorldStateUpdate> {
|
||||
let PreviousSectionState::Known(after) = after else {
|
||||
return None;
|
||||
return Vec::new();
|
||||
};
|
||||
let previous_snapshot;
|
||||
let previous = match before {
|
||||
@@ -55,9 +75,7 @@ fn render_diff<S: WorldStateSection>(
|
||||
PreviousSectionState::Known(&previous_snapshot)
|
||||
}
|
||||
};
|
||||
let mut fragments = expect_fragments(ErasedWorldStateSection::render_diff(*after, previous).1);
|
||||
assert!(fragments.len() <= 1, "expected at most one fragment");
|
||||
fragments.pop()
|
||||
ErasedWorldStateSection::render_diff(*after, previous).1
|
||||
}
|
||||
|
||||
fn render_snapshot<S: WorldStateSection>(section: &S) -> String {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 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.
|
||||
|
||||
use super::PreviousSectionState;
|
||||
use super::SectionTransition;
|
||||
use super::WorldStateHash;
|
||||
use super::WorldStateSection;
|
||||
use super::WorldStateUpdate;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::DeveloperInstructions;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The exact serialized Responses Lite declarations visible in one captured step.
|
||||
pub(crate) struct TopLevelToolsState {
|
||||
definitions: Vec<Value>,
|
||||
hashes: BTreeMap<String, WorldStateHash>,
|
||||
}
|
||||
|
||||
impl TopLevelToolsState {
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "the Responses Lite serializer emits objects with array-valued namespace members"
|
||||
)]
|
||||
pub(crate) fn new(definitions: Vec<Value>) -> Result<Self> {
|
||||
let mut hashes = BTreeMap::new();
|
||||
let mut insert_hash = |name: String, definition: &Value| -> Result<()> {
|
||||
let hash = WorldStateHash::from_json(definition);
|
||||
if hashes.insert(name.clone(), hash).is_some() {
|
||||
return Err(CodexErrorDetails::ToolCollision(name).into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for definition in &definitions {
|
||||
let name = definition_name(definition);
|
||||
let mut header = definition.clone();
|
||||
if let Some(tools) = header
|
||||
.as_object_mut()
|
||||
.expect("serialized tool object")
|
||||
.remove("tools")
|
||||
{
|
||||
for tool in tools.as_array().expect("serialized namespace members") {
|
||||
insert_hash(format!("{name}.{}", definition_name(tool)), tool)?;
|
||||
}
|
||||
}
|
||||
insert_hash(name.to_string(), &header)?;
|
||||
}
|
||||
Ok(Self {
|
||||
definitions,
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Input comes from create_tools_json_for_responses_lite: namespaces and their members have
|
||||
// names; built-ins are identified by type. Namespace members cannot contain namespaces.
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "the Responses Lite serializer gives every declaration a name or type"
|
||||
)]
|
||||
fn definition_name(definition: &Value) -> &str {
|
||||
definition["name"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| definition["type"].as_str().expect("serialized tool type"))
|
||||
}
|
||||
|
||||
impl WorldStateSection for TopLevelToolsState {
|
||||
const ID: &'static str = "top_level_tools";
|
||||
type Snapshot = BTreeMap<String, WorldStateHash>;
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> SectionTransition<Self::Snapshot> {
|
||||
let previous = match previous {
|
||||
PreviousSectionState::Known(previous) => Some(previous),
|
||||
PreviousSectionState::Absent | PreviousSectionState::Unknown => None,
|
||||
};
|
||||
let changed =
|
||||
|name: &str| previous.and_then(|previous| previous.get(name)) != self.hashes.get(name);
|
||||
let mut namespace_updates = Vec::new();
|
||||
let mut tools = Vec::new();
|
||||
for definition in &self.definitions {
|
||||
let name = definition_name(definition);
|
||||
let Some(members) = definition["tools"].as_array() else {
|
||||
if changed(name) {
|
||||
tools.push(definition.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let members = members
|
||||
.iter()
|
||||
.filter(|tool| changed(&format!("{name}.{}", definition_name(tool))))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !members.is_empty() {
|
||||
let mut namespace = definition.clone();
|
||||
namespace["tools"] = Value::Array(members);
|
||||
tools.push(namespace);
|
||||
} else if changed(name) {
|
||||
// Namespace declarations require a member. Update metadata as text without
|
||||
// repeating unchanged tool definitions.
|
||||
let instructions = definition["description"].as_str().unwrap_or_default();
|
||||
let text = if instructions.is_empty() {
|
||||
format!("The {name} namespace no longer has additional instructions.")
|
||||
} else {
|
||||
format!("Updated instructions for the {name} namespace:\n{instructions}")
|
||||
};
|
||||
namespace_updates
|
||||
.push(WorldStateUpdate::fragment(DeveloperInstructions::new(text)));
|
||||
}
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
if !tools.is_empty() {
|
||||
updates.push(WorldStateUpdate::prefix_item(
|
||||
ResponseItem::AdditionalTools {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
tools,
|
||||
},
|
||||
));
|
||||
}
|
||||
updates.extend(namespace_updates);
|
||||
if let Some(previous) = previous {
|
||||
let removed = previous
|
||||
.keys()
|
||||
.filter(|key| !self.hashes.contains_key(*key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !removed.is_empty() {
|
||||
updates.push(WorldStateUpdate::fragment(RemovedTools(removed)).standalone());
|
||||
}
|
||||
}
|
||||
// Persist the empty map too: it is a known empty catalog, not missing state.
|
||||
(Some(self.hashes.clone()), updates)
|
||||
}
|
||||
}
|
||||
|
||||
struct RemovedTools(Vec<String>);
|
||||
|
||||
impl ContextualUserFragment for RemovedTools {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn content_kind(&self) -> ContentItemKind {
|
||||
ContentItemKind("tools.removed_definition".to_string())
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let names = self.0.join("\n- ");
|
||||
format!("The following tools are no longer available. Do not call them:\n- {names}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "top_level_tools_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Catalog diffs preserve unchanged definitions, including tools inside namespaces.
|
||||
|
||||
use super::*;
|
||||
use crate::context::world_state::test_support::render_section_cases;
|
||||
use crate::context_manager::updates::merge_world_state_updates;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn declaration(name: &str) -> Value {
|
||||
json!({"type": "function", "name": name, "description": "Look up a value.",
|
||||
"parameters": {"type": "object", "properties": {}}})
|
||||
}
|
||||
|
||||
fn item(tools: Vec<Value>) -> ResponseItem {
|
||||
ResponseItem::AdditionalTools {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace(name: &str, tools: Vec<Value>) -> Value {
|
||||
json!({"type": "namespace", "name": name, "description": "Tools.", "tools": tools})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_catalog_transitions() {
|
||||
use PreviousSectionState::Absent;
|
||||
use PreviousSectionState::Known;
|
||||
|
||||
let search = json!({"type": "tool_search", "execution": "client",
|
||||
"description": "Search tools.",
|
||||
"parameters": {"type": "object", "properties": {}}});
|
||||
let web = json!({"type": "web_search", "external_web_access": true});
|
||||
let original = TopLevelToolsState::new(vec![search.clone()]).unwrap();
|
||||
let mut changed = search;
|
||||
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
|
||||
let updated = TopLevelToolsState::new(vec![changed, web.clone()]).unwrap();
|
||||
let only_web = TopLevelToolsState::new(vec![web]).unwrap();
|
||||
let empty = TopLevelToolsState::new(Vec::new()).unwrap();
|
||||
|
||||
insta::assert_snapshot!(render_section_cases(&[
|
||||
(Absent, Known(&original)),
|
||||
(Known(&original), Known(&original)),
|
||||
(Known(&original), Known(&updated)),
|
||||
(Known(&updated), Known(&only_web)),
|
||||
(Known(&only_web), Known(&empty)),
|
||||
(Known(&empty), Known(&original)),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_namespace_transitions() {
|
||||
use PreviousSectionState::Absent;
|
||||
use PreviousSectionState::Known;
|
||||
|
||||
let original = TopLevelToolsState::new(vec![
|
||||
namespace(
|
||||
"functions",
|
||||
vec![
|
||||
declaration("unchanged"),
|
||||
declaration("removed"),
|
||||
declaration("changed"),
|
||||
],
|
||||
),
|
||||
namespace("other", vec![declaration("lookup")]),
|
||||
])
|
||||
.unwrap();
|
||||
let mut changed = declaration("changed");
|
||||
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
|
||||
let updated = TopLevelToolsState::new(vec![
|
||||
namespace(
|
||||
"functions",
|
||||
vec![declaration("unchanged"), changed, declaration("added")],
|
||||
),
|
||||
namespace("other", vec![declaration("lookup")]),
|
||||
])
|
||||
.unwrap();
|
||||
let only_other =
|
||||
TopLevelToolsState::new(vec![namespace("other", vec![declaration("lookup")])]).unwrap();
|
||||
|
||||
insta::assert_snapshot!(render_section_cases(&[
|
||||
(Absent, Known(&original)),
|
||||
(Known(&original), Known(&original)),
|
||||
(Known(&original), Known(&updated)),
|
||||
(Known(&updated), Known(&only_other)),
|
||||
(Known(&only_other), Known(&original)),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_catalog_preserves_order_and_unchanged_catalog_emits_nothing() {
|
||||
let definitions = vec![
|
||||
namespace("zebra", vec![declaration("lookup")]),
|
||||
namespace("alpha", vec![declaration("lookup")]),
|
||||
];
|
||||
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)]);
|
||||
let snapshot = snapshot.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["alpha", "alpha.lookup", "zebra", "zebra.lookup"]
|
||||
);
|
||||
let (unchanged_snapshot, updates) = state.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(unchanged_snapshot, Some(snapshot));
|
||||
assert!(updates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_is_persisted_and_can_add_tools_again() {
|
||||
let definitions = vec![namespace("functions", vec![declaration("lookup")])];
|
||||
let original = TopLevelToolsState::new(definitions.clone()).unwrap();
|
||||
let previous = original
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.0
|
||||
.unwrap();
|
||||
let empty = TopLevelToolsState::new(Vec::new()).unwrap();
|
||||
let (snapshot, updates) = empty.render_diff(PreviousSectionState::Known(&previous));
|
||||
assert_eq!(snapshot, Some(BTreeMap::new()));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![ContextualUserFragment::into(RemovedTools(vec![
|
||||
"functions".to_string(),
|
||||
"functions.lookup".to_string(),
|
||||
]))]
|
||||
);
|
||||
let (_, updates) = original.render_diff(PreviousSectionState::Known(&snapshot.unwrap()));
|
||||
assert_eq!(merge_world_state_updates(updates), vec![item(definitions)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_diff_contains_only_changed_and_added_tools() {
|
||||
let original = TopLevelToolsState::new(vec![
|
||||
namespace(
|
||||
"functions",
|
||||
vec![
|
||||
declaration("changed"),
|
||||
declaration("unchanged"),
|
||||
declaration("removed"),
|
||||
],
|
||||
),
|
||||
namespace("other", vec![declaration("changed")]),
|
||||
])
|
||||
.unwrap();
|
||||
let snapshot = original
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.0
|
||||
.unwrap();
|
||||
let mut changed = declaration("changed");
|
||||
changed["parameters"]["properties"] = json!({"query": {"type": "string"}});
|
||||
let added = declaration("added");
|
||||
let current = TopLevelToolsState::new(vec![
|
||||
namespace(
|
||||
"functions",
|
||||
vec![changed.clone(), declaration("unchanged"), added.clone()],
|
||||
),
|
||||
namespace("other", vec![declaration("changed")]),
|
||||
])
|
||||
.unwrap();
|
||||
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![
|
||||
item(vec![namespace("functions", vec![changed, added])]),
|
||||
ContextualUserFragment::into(RemovedTools(vec!["functions.removed".to_string()])),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
current
|
||||
.render_diff(PreviousSectionState::Known(&snapshot.unwrap()))
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_description_change_does_not_repeat_unchanged_tools() {
|
||||
let original =
|
||||
TopLevelToolsState::new(vec![namespace("functions", vec![declaration("lookup")])]).unwrap();
|
||||
let snapshot = original
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.0
|
||||
.unwrap();
|
||||
let mut updated = namespace("functions", vec![declaration("lookup")]);
|
||||
updated["description"] = json!("Updated namespace guidance.");
|
||||
let current = TopLevelToolsState::new(vec![updated]).unwrap();
|
||||
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![ContextualUserFragment::into(DeveloperInstructions::new(
|
||||
"Updated instructions for the functions namespace:\nUpdated namespace guidance."
|
||||
))]
|
||||
);
|
||||
assert!(
|
||||
current
|
||||
.render_diff(PreviousSectionState::Known(&snapshot.unwrap()))
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_type_change_keeps_the_callable_name_available() {
|
||||
let original = TopLevelToolsState::new(vec![namespace(
|
||||
"functions",
|
||||
vec![declaration("lookup"), declaration("removed")],
|
||||
)])
|
||||
.unwrap();
|
||||
let snapshot = original
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.0
|
||||
.unwrap();
|
||||
let custom = json!({"type": "custom", "name": "lookup", "description": "Look up a value.",
|
||||
"format": {"type": "text"}});
|
||||
let updated = namespace("functions", vec![custom]);
|
||||
let current = TopLevelToolsState::new(vec![updated.clone()]).unwrap();
|
||||
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![
|
||||
item(vec![updated]),
|
||||
ContextualUserFragment::into(RemovedTools(vec!["functions.removed".to_string()])),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_removals_emit_one_notice() {
|
||||
let original = TopLevelToolsState::new(vec![namespace(
|
||||
"functions",
|
||||
vec![
|
||||
declaration("lookup"),
|
||||
declaration("removed"),
|
||||
declaration("also_removed"),
|
||||
],
|
||||
)])
|
||||
.unwrap();
|
||||
let snapshot = original
|
||||
.render_diff(PreviousSectionState::Absent)
|
||||
.0
|
||||
.unwrap();
|
||||
let current =
|
||||
TopLevelToolsState::new(vec![namespace("functions", vec![declaration("lookup")])]).unwrap();
|
||||
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![ContextualUserFragment::into(RemovedTools(vec![
|
||||
"functions.also_removed".to_string(),
|
||||
"functions.removed".to_string(),
|
||||
]))]
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,22 @@ fn world_state_hash_normalizes_crlf_line_endings() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_state_json_hash_ignores_object_key_order() {
|
||||
let first: Value = serde_json::from_str(
|
||||
r#"{"type":"function","parameters":{"type":"object","properties":{"query":{"type":"string"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let reordered: Value = serde_json::from_str(
|
||||
r#"{"parameters":{"properties":{"query":{"type":"string"}},"type":"object"},"type":"function"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
WorldStateHash::from_json(&first),
|
||||
WorldStateHash::from_json(&reordered),
|
||||
);
|
||||
}
|
||||
|
||||
struct DuplicateTestSection;
|
||||
|
||||
impl WorldStateSection for DuplicateTestSection {
|
||||
|
||||
@@ -49,6 +49,15 @@ pub(crate) struct StepContext {
|
||||
}
|
||||
|
||||
impl StepContext {
|
||||
pub(crate) fn uses_incremental_tools(&self) -> bool {
|
||||
self.settings.model_info.use_responses_lite
|
||||
&& self
|
||||
.turn
|
||||
.config
|
||||
.features
|
||||
.enabled(codex_features::Feature::IncrementalTools)
|
||||
}
|
||||
|
||||
/// Pairs the step's environments with access using current session and originating-turn grants.
|
||||
pub(crate) fn environments(&self) -> Vec<(&TurnEnvironment, impl EnvironmentAccess + '_)> {
|
||||
self.environments
|
||||
|
||||
@@ -1605,7 +1605,11 @@ pub(crate) fn build_prompt(
|
||||
let turn_context = &step_context.turn;
|
||||
Prompt {
|
||||
input,
|
||||
tools: step_context.tool_router.model_visible_specs(),
|
||||
tools: if step_context.uses_incremental_tools() {
|
||||
Arc::default()
|
||||
} else {
|
||||
step_context.tool_router.model_visible_specs()
|
||||
},
|
||||
parallel_tool_calls: true,
|
||||
base_instructions,
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::context::world_state::PersistentModeState;
|
||||
use crate::context::world_state::PluginsInstructionsState;
|
||||
use crate::context::world_state::RealtimeState;
|
||||
use crate::context::world_state::ToolsState;
|
||||
use crate::context::world_state::TopLevelToolsState;
|
||||
use crate::context::world_state::WorldState;
|
||||
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
|
||||
use codex_connectors::AppToolPolicyEvaluator;
|
||||
@@ -117,6 +118,11 @@ impl Session {
|
||||
String::new()
|
||||
};
|
||||
let mut world_state = WorldState::default();
|
||||
if step_context.uses_incremental_tools() {
|
||||
let specs = step_context.tool_router.model_visible_specs();
|
||||
let definitions = codex_tools::create_tools_json_for_responses_lite(&specs)?;
|
||||
world_state.add_section(TopLevelToolsState::new(definitions)?);
|
||||
}
|
||||
world_state.add_section(ModelInstructionsState::new(
|
||||
&model_info.slug,
|
||||
previous_model.as_deref(),
|
||||
|
||||
@@ -355,9 +355,14 @@ fn portable_tool_schema_normalizes_embedded_code_mode_shell_guidance() {
|
||||
let description = |shell: String, wait: &str| {
|
||||
format!("### `exec_command`\n{shell}\n\nexec tool declaration:\n```ts\n // {wait}\n```")
|
||||
};
|
||||
let nested = |description| {
|
||||
let nested = |description: String| {
|
||||
let shell_description = description
|
||||
.strip_prefix("### `exec_command`\n")
|
||||
.expect("embedded shell guidance")
|
||||
.to_string();
|
||||
json!({ "type": "namespace", "name": "functions", "tools": [
|
||||
{ "type": "custom", "name": "exec", "description": description }
|
||||
{ "type": "custom", "name": "exec", "description": description },
|
||||
{ "type": "function", "name": "exec_command", "description": shell_description }
|
||||
] })
|
||||
};
|
||||
let unix = nested(description(
|
||||
|
||||
@@ -497,13 +497,21 @@ pub(super) fn portable_tool_schema(tool: &Value) -> Value {
|
||||
if definition.get("name").and_then(Value::as_str) != Some("exec_command") {
|
||||
return stable;
|
||||
}
|
||||
if let Some(Value::String(description)) = definition.get_mut("description")
|
||||
&& description.strip_prefix(BASE).is_some_and(|rest| {
|
||||
rest.starts_with("\n\nWindows safety rules:")
|
||||
&& fnv1a(normalize_line_endings(rest).as_bytes()) == WINDOWS_SAFETY_SUFFIX_HASH
|
||||
})
|
||||
{
|
||||
*description = BASE.to_string();
|
||||
if let Some(Value::String(description)) = definition.get_mut("description") {
|
||||
if let Some(rest) = description.strip_prefix(BASE) {
|
||||
let suffix = rest
|
||||
.split_once("\n\nexec tool declaration:")
|
||||
.map_or(rest, |(suffix, _)| suffix);
|
||||
if suffix.starts_with("\n\nWindows safety rules:")
|
||||
&& fnv1a(normalize_line_endings(suffix).as_bytes()) == WINDOWS_SAFETY_SUFFIX_HASH
|
||||
{
|
||||
*description = format!("{BASE}{}", &rest[suffix.len()..]);
|
||||
}
|
||||
}
|
||||
*description = description.replace(
|
||||
&format!(" // {WINDOWS_WAIT}"),
|
||||
&format!(" // {UNIX_WAIT}"),
|
||||
);
|
||||
}
|
||||
if let Some(Value::String(wait)) =
|
||||
definition.pointer_mut("/parameters/properties/yield_time_ms/description")
|
||||
|
||||
@@ -2048,18 +2048,13 @@ async fn responses_lite_websocket_uses_incremental_create_on_prefix() {
|
||||
let second = connection.get(1).expect("missing request").body_json();
|
||||
let first_input = first["input"].as_array().expect("request input");
|
||||
|
||||
assert_eq!(first_input.len(), 3);
|
||||
assert_eq!(first_input.len(), 2);
|
||||
assert!(
|
||||
first_input[0]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("at_"))
|
||||
);
|
||||
assert!(
|
||||
first_input[1]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("msg_"))
|
||||
);
|
||||
assert_eq!(first_input[2]["id"], "msg_supplied");
|
||||
assert_eq!(first_input[1]["id"], "msg_supplied");
|
||||
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
||||
assert_eq!(
|
||||
second["input"],
|
||||
|
||||
@@ -89,6 +89,9 @@ use tokio::sync::oneshot;
|
||||
|
||||
const ONE_PIXEL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
|
||||
#[path = "scenarios_incremental_tools.rs"]
|
||||
mod incremental_tools;
|
||||
|
||||
#[path = "scenarios_agent_message_board.rs"]
|
||||
mod agent_message_board;
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Request-history coverage for opt-in incremental tools on Responses Lite.
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_features::Feature;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_protocol::openai_models::CodeModeToolMessages;
|
||||
use codex_protocol::openai_models::ToolMessage;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
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::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
|
||||
#[test_case(true; "responses_lite")]
|
||||
#[test_case(false; "responses_api")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn incremental_tools_append_changed_catalog_without_rewriting_history(
|
||||
use_responses_lite: bool,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
(1..=3)
|
||||
.map(|index| responses::sse(vec![responses::ev_completed(&format!("resp-{index}"))]))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model_info_override("gpt-5.4", move |model| {
|
||||
model.use_responses_lite = use_responses_lite;
|
||||
model.tool_mode = Some(ToolMode::CodeMode);
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::IncrementalTools)
|
||||
.expect("enable incremental tools");
|
||||
config.base_instructions =
|
||||
Some("Use the available tools to help the user.".to_string());
|
||||
config.code_mode.disable_in_process_fallback = true;
|
||||
let catalog = config.model_catalog.as_mut().expect("model catalog");
|
||||
let mut updated = catalog
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.slug == "gpt-5.4")
|
||||
.expect("source model")
|
||||
.clone();
|
||||
updated.slug = "updated-tools-model".to_string();
|
||||
updated
|
||||
.model_messages
|
||||
.get_or_insert_default()
|
||||
.tools
|
||||
.get_or_insert_default()
|
||||
.code_mode = Some(CodeModeToolMessages {
|
||||
exec: Some(ToolMessage {
|
||||
description: Some("Updated execution instructions.".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
catalog.models.push(updated);
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
test.submit_turn("Begin work.").await?;
|
||||
test.submit_turn("Continue with the same tools.").await?;
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
ThreadSettingsOverrides {
|
||||
model: Some("updated-tools-model".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("Continue with the updated execution instructions.")
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
if !use_responses_lite {
|
||||
for request in &requests {
|
||||
let body = request.body_json();
|
||||
assert!(
|
||||
body["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|tools| !tools.is_empty())
|
||||
);
|
||||
assert_eq!(
|
||||
body["instructions"],
|
||||
"Use the available tools to help the user."
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
requests[0].body_json()["tools"],
|
||||
requests[1].body_json()["tools"]
|
||||
);
|
||||
assert_ne!(
|
||||
requests[1].body_json()["tools"],
|
||||
requests[2].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
|
||||
.iter()
|
||||
.all(|request| request.body_json().get("tools").is_none())
|
||||
);
|
||||
let initial = requests[0].inputs_of_type("additional_tools");
|
||||
assert_eq!(initial.len(), 1);
|
||||
assert_eq!(
|
||||
initial[0]["tools"]
|
||||
.as_array()
|
||||
.expect("initial tool declarations")
|
||||
.iter()
|
||||
.map(|tool| tool["type"].clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![json!("namespace"), json!("tool_search")]
|
||||
);
|
||||
assert_eq!(requests[1].inputs_of_type("additional_tools"), initial);
|
||||
let changed = requests[2].inputs_of_type("additional_tools");
|
||||
assert_eq!(requests[2].body_json()["model"], "updated-tools-model");
|
||||
assert_eq!(&changed[..initial.len()], &initial);
|
||||
assert_eq!(changed.len(), initial.len() + 1);
|
||||
let diff = changed.last().expect("changed tool declarations")["tools"]
|
||||
.as_array()
|
||||
.expect("changed tool array");
|
||||
assert_eq!(diff.len(), 1);
|
||||
assert_eq!(diff[0]["name"], "functions");
|
||||
let changed_tools = diff[0]["tools"].as_array().expect("namespace members");
|
||||
assert_eq!(changed_tools.len(), 1);
|
||||
assert_eq!(changed_tools[0]["name"], "exec");
|
||||
assert!(
|
||||
changed
|
||||
.last()
|
||||
.expect("changed tool declarations")
|
||||
.to_string()
|
||||
.contains("Updated execution instructions.")
|
||||
);
|
||||
|
||||
test.codex.ensure_rollout_materialized().await;
|
||||
test.codex.flush_rollout().await?;
|
||||
let rollout =
|
||||
tokio::fs::read_to_string(test.codex.rollout_path().expect("rollout path")).await?;
|
||||
let catalogs = rollout
|
||||
.lines()
|
||||
.map(codex_rollout::parse_rollout_line)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter_map(|line| match line.item {
|
||||
RolloutItem::WorldState(item) => item.state.get("top_level_tools").cloned(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(catalogs.len(), 2);
|
||||
assert_ne!(catalogs[0], catalogs[1]);
|
||||
assert_eq!(
|
||||
catalogs[1]
|
||||
.as_object()
|
||||
.expect("updated tool catalog")
|
||||
.iter()
|
||||
.filter(|(key, hash)| catalogs[0].get(*key) != Some(*hash))
|
||||
.map(|(key, _)| key.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["functions.exec"]
|
||||
);
|
||||
assert!(catalogs.iter().all(|catalog| {
|
||||
catalog
|
||||
.as_object()
|
||||
.expect("tool catalog")
|
||||
.values()
|
||||
.all(|hash| hash.as_str().is_some_and(|hash| hash.len() == 40))
|
||||
}));
|
||||
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.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default()
|
||||
.rewrite_known_segments()
|
||||
.include_request_settings(),
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
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(),)"
|
||||
---
|
||||
Scenario: Tool definitions enter history in one batch; a catalog change appends only the changed exec definition.
|
||||
|
||||
## Window 1
|
||||
Settings:
|
||||
include: ["reasoning.encrypted_content"]
|
||||
model: "gpt-5.4"
|
||||
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 (2; hash=1e63b25da7155640):
|
||||
- namespace/functions; hash=cc6eebc2891d0fea
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/exec_command
|
||||
- function/write_stdin
|
||||
- function/request_user_input
|
||||
- custom/apply_patch
|
||||
- function/view_image
|
||||
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
04:message/user:
|
||||
Begin work.
|
||||
-- request 2 (turn) --
|
||||
05:message/user:
|
||||
Continue with the same tools.
|
||||
|
||||
## Window 2 (after request 2)
|
||||
Settings: relative to window 1
|
||||
model: "updated-tools-model"
|
||||
-- request 3 (turn) --
|
||||
00:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
01:additional_tools/developer (2; hash=1e63b25da7155640):
|
||||
- namespace/functions; hash=cc6eebc2891d0fea
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/exec_command
|
||||
- function/write_stdin
|
||||
- function/request_user_input
|
||||
- custom/apply_patch
|
||||
- function/view_image
|
||||
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
04:message/user:
|
||||
Begin work.
|
||||
05:message/user:
|
||||
Continue with the same tools.
|
||||
06:additional_tools/developer (1; hash=458b87f50d4f6057):
|
||||
- namespace/functions; hash=3045d6c5c31286d9
|
||||
- custom/exec
|
||||
07:message/developer:
|
||||
<MODEL_SWITCH>
|
||||
08:message/user:
|
||||
Continue with the updated execution instructions.
|
||||
Reference in New Issue
Block a user