diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9a450c9d92..3b6c84d4d6 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -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(), diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 4e76ac5905..7a870f4151 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -24,8 +24,7 @@ pub struct Prompt { /// Conversation context input items. pub input: Vec, - /// 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. diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 1562fb1958..38f58dbde9 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -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(), diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index 6f881e2868..ee4b3d900c 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -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, diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 92b89cc085..798933e593 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -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), - #[cfg_attr( - not(test), - expect(dead_code, reason = "Item producers are added separately") - )] Item(Box), } @@ -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 = (Option, Vec); @@ -338,12 +339,18 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static { ) -> SectionTransition; } -/// 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"); diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_catalog_transitions.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_catalog_transitions.snap new file mode 100644 index 0000000000..a0fd37ebc4 --- /dev/null +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_catalog_transitions.snap @@ -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" +} diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap new file mode 100644 index 0000000000..e6e7956f1d --- /dev/null +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__top_level_tools__tests__snapshots_namespace_transitions.snap @@ -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" +} diff --git a/codex-rs/core/src/context/world_state/test_support.rs b/codex-rs/core/src/context/world_state/test_support.rs index b598644171..f6c42373e4 100644 --- a/codex-rs/core/src/context/world_state/test_support.rs +++ b/codex-rs/core/src/context/world_state/test_support.rs @@ -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::>() + .join("\n") + }; format!( - "{} -> {}{role}\n{content}", + "{} -> {}{rendered}", render_state(before), render_state(after), ) @@ -42,9 +62,9 @@ fn render_state(state: &PreviousSectionState<'_, S>) -> St fn render_diff( before: &PreviousSectionState<'_, S>, after: &PreviousSectionState<'_, S>, -) -> Option> { +) -> Vec { 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( 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(section: &S) -> String { diff --git a/codex-rs/core/src/context/world_state/top_level_tools.rs b/codex-rs/core/src/context/world_state/top_level_tools.rs new file mode 100644 index 0000000000..a4b61e9656 --- /dev/null +++ b/codex-rs/core/src/context/world_state/top_level_tools.rs @@ -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, + hashes: BTreeMap, +} + +impl TopLevelToolsState { + #[expect( + clippy::expect_used, + reason = "the Responses Lite serializer emits objects with array-valued namespace members" + )] + pub(crate) fn new(definitions: Vec) -> Result { + 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; + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> SectionTransition { + 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::>(); + 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::>(); + 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); + +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; diff --git a/codex-rs/core/src/context/world_state/top_level_tools_tests.rs b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs new file mode 100644 index 0000000000..5a35ae9cb1 --- /dev/null +++ b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs @@ -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) -> ResponseItem { + ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools, + } +} + +fn namespace(name: &str, tools: Vec) -> 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!["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(), + ]))] + ); +} diff --git a/codex-rs/core/src/context/world_state/world_state_tests.rs b/codex-rs/core/src/context/world_state/world_state_tests.rs index 7ea6efb898..a5ceddc775 100644 --- a/codex-rs/core/src/context/world_state/world_state_tests.rs +++ b/codex-rs/core/src/context/world_state/world_state_tests.rs @@ -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 { diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 2b2428ccf2..9bc54aec53 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -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 diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 03aa0f197e..96456c77eb 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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(), diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index b11748587f..109fe1cee8 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -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(), diff --git a/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs b/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs index 94208c0929..57be655997 100644 --- a/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs +++ b/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs @@ -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( diff --git a/codex-rs/core/tests/common/context_snapshot/normalize.rs b/codex-rs/core/tests/common/context_snapshot/normalize.rs index a4da82ad46..1c086f37fc 100644 --- a/codex-rs/core/tests/common/context_snapshot/normalize.rs +++ b/codex-rs/core/tests/common/context_snapshot/normalize.rs @@ -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") diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 04dc933444..b2d1adc3ad 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -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"], diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 998fdf91f8..07401f7b92 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -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; diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs new file mode 100644 index 0000000000..6c26a5495b --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -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![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::, _>>()? + .into_iter() + .filter_map(|line| match line.item { + RolloutItem::WorldState(item) => item.state.get("top_level_tools").cloned(), + _ => None, + }) + .collect::>(); + 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!["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(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap new file mode 100644 index 0000000000..4cac8fa44c --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap @@ -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: "" + reasoning: {"context":"all_turns","effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:message/developer: + +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: + +03:message/user: + +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: + +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: + +03:message/user: + +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: + +08:message/user: + Continue with the updated execution instructions.