mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user