From f088e0c4ffdfab3a7d4480c14855a9db00450384 Mon Sep 17 00:00:00 2001 From: Steve Coffey Date: Tue, 27 Oct 2026 19:38:09 +0000 Subject: [PATCH] Allow replacing dynamic tools between model steps (#57553) ## What changed Add `CodexThread::replace_dynamic_tools` to replace the tool catalog without starting a turn. Updates apply to subsequent model steps and future turns while preserving in-flight calls; an empty list clears the catalog. Require host opt-in through `allow_persistent_dynamic_tool_updates` for persistent threads. Persist replacements before publishing them, restore the latest catalog on resume, and preserve replacements and explicit clears through compaction. Keep explicit resume overrides separate from the persisted catalog. ## Testing Add coverage for pending calls during replacement, replacement and clearing across restart, compaction recovery, resume overrides, submission ordering after caller cancellation, host opt-in, and persistence failures. GitOrigin-RevId: fd2634906895a9b72ae7892ddd9435dced45eccb --- .../app-server-exports-stable.json.zst | Bin 161637 -> 161746 bytes .../src/protocol/thread_history.rs | 1 + .../src/protocol/thread_history_projection.rs | 3 +- codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 3 +- codex-rs/core/src/agent/control_tests.rs | 7 +- codex-rs/core/src/agent/history.rs | 8 +- codex-rs/core/src/codex_thread.rs | 18 ++ codex-rs/core/src/config/mod.rs | 5 + .../core/src/session/dynamic_tools_tests.rs | 143 ++++++++++ .../session/extension_interruption_tests.rs | 43 +-- codex-rs/core/src/session/handlers.rs | 4 + codex-rs/core/src/session/mod.rs | 13 +- .../src/session/rollout_reconstruction.rs | 8 +- .../session/rollout_reconstruction_tests.rs | 1 + codex-rs/core/src/session/session.rs | 26 +- codex-rs/core/src/session/tests.rs | 132 ++++++++- codex-rs/core/src/session/thread_settings.rs | 39 +++ codex-rs/core/src/session/turn.rs | 2 + codex-rs/core/src/session/turn_context.rs | 4 - codex-rs/core/src/state/session.rs | 5 + .../core/src/stream_events_utils_tests.rs | 9 +- codex-rs/core/src/tools/router_tests.rs | 14 +- codex-rs/core/src/tools/spec_plan.rs | 3 +- codex-rs/core/src/tools/spec_plan_tests.rs | 4 + codex-rs/core/tests/suite/scenarios.rs | 3 + .../tests/suite/scenarios_dynamic_tools.rs | 261 ++++++++++++++++++ ...ves_pending_calls_and_survives_resume.snap | 112 ++++++++ .../src/sessions/append.rs | 2 + .../history/src/compaction_resume_metadata.rs | 17 +- codex-rs/history/src/lib.rs | 43 ++- codex-rs/history/src/rollout_payload.rs | 19 ++ codex-rs/history/src/tests.rs | 82 +++++- codex-rs/memories/write/src/phase1.rs | 3 +- codex-rs/memories/write/src/rollout_input.rs | 3 +- codex-rs/protocol/src/protocol.rs | 8 + codex-rs/rollout/src/list.rs | 3 + codex-rs/rollout/src/metadata.rs | 2 + codex-rs/rollout/src/persistence_metrics.rs | 1 + codex-rs/rollout/src/policy.rs | 1 + codex-rs/rollout/src/recorder.rs | 3 +- codex-rs/rollout/src/search.rs | 3 +- codex-rs/state/src/extract.rs | 5 +- codex-rs/state/src/runtime/threads.rs | 1 + codex-rs/thread-manager-sample/src/main.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../local/rollout_migration/canonicalizer.rs | 1 + .../local/rollout_migration/rollback_plan.rs | 4 +- .../rollout_migration/rollback_replay.rs | 3 +- .../thread-store/src/thread_metadata_sync.rs | 3 +- 50 files changed, 976 insertions(+), 105 deletions(-) create mode 100644 codex-rs/core/src/session/dynamic_tools_tests.rs create mode 100644 codex-rs/core/tests/suite/scenarios_dynamic_tools.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__dynamic_tools__dynamic_tools_update_preserves_pending_calls_and_survives_resume.snap diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index f57f36380573f18e553d64c25771a6d12ed1da5e..065fefedd02845eb6934c9853197ed0184bed3a6 100644 GIT binary patch delta 22440 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 diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index b63b84895b..c087791283 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -491,6 +491,7 @@ impl ThreadHistoryBuilder { | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SessionMeta(_) => {} } } diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs index 32d61bf82c..9f6f345e12 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs @@ -82,7 +82,8 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { }], ..Default::default() }, - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 5c9fcdd84b..97cb3f90bd 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -105,6 +105,7 @@ pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; pub use codex_history::InitialHistory; +pub use codex_history::latest_dynamic_tools; pub use codex_home::CodexHomeUserInstructionsProvider; pub use codex_image_generation_extension::install as install_image_generation_extension; pub use codex_login::AuthHeaders; diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 8b752ea199..16323358e1 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -742,7 +742,8 @@ impl LocalAgentControl { if multi_agent_version == MultiAgentVersion::V2 && config.features.enabled(Feature::MultiAgentV2DynamicTools) { - start_options.dynamic_tools = parent_thread.session.dynamic_tools().await; + start_options.dynamic_tools = + parent_thread.session.dynamic_tools().await.to_vec(); } } None diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 59027230a4..01d22c744b 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -2614,8 +2614,8 @@ async fn spawn_agent_without_fork_from_paginated_parent_stays_fresh_and_paginate Vec::new() }; assert_eq!( - child_thread.session.dynamic_tools().await, - expected_dynamic_tools + child_thread.session.dynamic_tools().await.as_ref(), + expected_dynamic_tools.as_slice() ); assert!( !history_contains_text( @@ -2661,6 +2661,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() { let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; let parent_resume_metadata = codex_history::CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: Some("parent-turn".to_string()), turn_attribution: Some(codex_history::TurnAttribution { @@ -3818,6 +3819,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc compaction_response_id: None, latest_token_usage_record: None, resume_metadata: Some(codex_history::CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, turn_attribution: None, @@ -3952,6 +3954,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc assert_eq!( inherited_state, Some(codex_history::CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, turn_attribution: None, diff --git a/codex-rs/core/src/agent/history.rs b/codex-rs/core/src/agent/history.rs index 0549c0c74d..9cba9c4260 100644 --- a/codex-rs/core/src/agent/history.rs +++ b/codex-rs/core/src/agent/history.rs @@ -82,7 +82,10 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_context_baselines: bool RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) => preserve_context_baselines, // Child threads inherit model context, not the parent's cumulative usage state. RolloutItem::TokenUsageRecord(_) => false, - RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) + | RolloutItem::SessionMeta(_) => true, } } @@ -417,7 +420,8 @@ pub(super) async fn prepare_forked_history( true } RolloutItem::RealtimeItem(_) => false, - RolloutItem::EventMsg(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) | RolloutItem::InterAgentCommunication(_) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f93cc4a4b2..63fef3ceed 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -719,6 +719,24 @@ impl CodexThread { self.session.update_settings(updates).await.map(|_| ()) } + /// Replaces tools for subsequent model steps and future turns, preserving in-flight calls. + /// An empty list removes all dynamic tools. Persistent threads require host opt-in + /// so every reader can restore successful updates on resume. + /// + /// Once queued, stopping the caller does not cancel the update. + /// A persistence error can leave the durable result indeterminate; retry the same replacement + /// before continuing. + pub async fn replace_dynamic_tools( + &self, + tools: Vec, + ) -> CodexResult<()> { + let (reply, result) = oneshot::channel(); + self.io + .submit(Op::ReplaceDynamicTools { tools, reply }) + .await?; + result.await.unwrap_or(Err(CodexErr::InternalAgentDied)) + } + /// Persists current settings without emitting a live settings event. /// /// Serializes snapshot capture and persistence with accepted settings updates. diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c2f12e60e5..a6640ad139 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1013,6 +1013,10 @@ pub struct Config { /// When true, session is not persisted on disk. Default to `false` pub ephemeral: bool, + /// Enable persistent dynamic tool updates only when every reader, including rollback + /// versions, can restore the updated tools and preserve them through compaction. + pub allow_persistent_dynamic_tool_updates: bool, + /// Optional extra configuration fields for the thread. pub extra_config: Option, @@ -4627,6 +4631,7 @@ impl Config { application_auth_route_config: None, history, ephemeral: ephemeral.unwrap_or_default(), + allow_persistent_dynamic_tool_updates: false, extra_config: None, bypass_hook_trust, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), diff --git a/codex-rs/core/src/session/dynamic_tools_tests.rs b/codex-rs/core/src/session/dynamic_tools_tests.rs new file mode 100644 index 0000000000..0aaa535412 --- /dev/null +++ b/codex-rs/core/src/session/dynamic_tools_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn dynamic_tools_updates_preserve_submission_order_after_caller_stops_waiting() { + let (mut session, turn_context, rx_event) = + make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| config.allow_persistent_dynamic_tool_updates = true, + ) + .await; + let rollout_path = + attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; + let (thread, rx_sub) = make_thread_for_session( + Arc::clone(&session), + &turn_context, + rx_event, + SessionSource::Exec, + ) + .await; + let replacement = vec![DynamicToolSpec::Function( + codex_protocol::dynamic_tools::DynamicToolFunctionSpec { + name: "check_release".into(), + description: "Check the release.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + )]; + let persistence = thread_settings::acquire_persistence_lock(&session).await; + let mut first = Box::pin(tokio::task::unconstrained( + thread.replace_dynamic_tools(Vec::new()), + )); + assert!(futures::poll!(first.as_mut()).is_pending()); + drop(first); + let mut submissions = Box::pin(tokio::task::unconstrained(submission_loop( + Arc::clone(&session), + Arc::clone(&turn_context.config), + rx_sub, + ))); + assert!(futures::poll!(submissions.as_mut()).is_pending()); + let mut last = Box::pin(tokio::task::unconstrained( + thread.replace_dynamic_tools(replacement.clone()), + )); + assert!(futures::poll!(last.as_mut()).is_pending()); + let mut shutdown = Box::pin(tokio::task::unconstrained(thread.submit(Op::Shutdown))); + // Shutdown cannot overtake the accepted replacements while persistence is blocked. + assert!(futures::poll!(shutdown.as_mut()).is_pending()); + assert_eq!(session.dynamic_tools().await.as_ref(), &[]); + drop(persistence); + let ((), updated, stopped) = tokio::join!(submissions, last, shutdown); + updated.expect("replace dynamic tools before shutdown"); + stopped.expect("submit shutdown"); + assert_eq!( + session.dynamic_tools().await.as_ref(), + replacement.as_slice() + ); + let restored = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read persisted catalog"); + let catalogs: Vec<_> = restored + .get_rollout_items() + .iter() + .filter_map(|item| match item { + RolloutItem::DynamicToolsUpdated { tools, .. } => Some(tools.clone()), + _ => None, + }) + .collect(); + assert_eq!(catalogs, vec![Vec::new(), replacement.clone()]); + assert_eq!(restored.get_dynamic_tools(), Some(replacement)); + assert!(matches!( + thread + .replace_dynamic_tools(Vec::new()) + .await + .unwrap_err() + .details(), + codex_protocol::error::CodexErrorDetails::InternalAgentDied + )); +} + +#[tokio::test] +async fn dynamic_tools_update_requires_host_opt_in_only_for_persistent_threads() { + let (mut session, _) = make_session_and_context().await; + let initial_tools = vec![DynamicToolSpec::Function( + codex_protocol::dynamic_tools::DynamicToolFunctionSpec { + name: "check_release".into(), + description: "Check the release.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + )]; + session + .replace_dynamic_tools(initial_tools.clone()) + .await + .unwrap(); + let path = attach_thread_persistence(&mut session).await; + let error = session.replace_dynamic_tools(Vec::new()).await.unwrap_err(); + assert!(matches!( + error.details(), + codex_protocol::error::CodexErrorDetails::UnsupportedOperation(_) + )); + assert_eq!( + session.dynamic_tools().await.as_ref(), + initial_tools.as_slice() + ); + session.flush_rollout().await.unwrap(); + let history = RolloutRecorder::get_rollout_history(&path).await.unwrap(); + assert_eq!(history.get_dynamic_tools(), Some(initial_tools)); +} + +#[tokio::test] +async fn dynamic_tools_update_keeps_catalog_when_persistence_fails() { + let (mut session, _, _rx) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| config.allow_persistent_dynamic_tool_updates = true, + ) + .await; + attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; + session + .live_thread() + .expect("live thread") + .shutdown() + .await + .expect("close persistence"); + let replacement = vec![codex_protocol::dynamic_tools::DynamicToolSpec::Function( + codex_protocol::dynamic_tools::DynamicToolFunctionSpec { + name: "check_release".into(), + description: "Check the release.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + )]; + let error = session + .replace_dynamic_tools(replacement) + .await + .unwrap_err(); + assert!(matches!( + error.details(), + codex_protocol::error::CodexErrorDetails::Fatal(_) + )); + assert_eq!(session.dynamic_tools().await.as_ref(), &[]); +} diff --git a/codex-rs/core/src/session/extension_interruption_tests.rs b/codex-rs/core/src/session/extension_interruption_tests.rs index 39e038bc7a..319da2a21c 100644 --- a/codex-rs/core/src/session/extension_interruption_tests.rs +++ b/codex-rs/core/src/session/extension_interruption_tests.rs @@ -2,16 +2,10 @@ #![cfg(test)] use super::*; -use crate::session::SessionIo; -use crate::session::completed_session_loop_termination; use crate::session::tests::HeldStepTask; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::tests::make_thread_for_session; use crate::state::TaskKind; -use crate::thread_startup_metadata::ThreadStartupMetadata; -use codex_protocol::config_types::ApprovalsReviewer; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use opentelemetry::trace::TracerProvider; use opentelemetry_sdk::trace::InMemorySpanExporter; @@ -19,7 +13,6 @@ use opentelemetry_sdk::trace::SdkTracerProvider; use pretty_assertions::assert_eq; use std::time::Duration; use tokio::sync::Notify; -use tokio::sync::watch; use tracing::Instrument; use tracing_subscriber::prelude::*; @@ -136,37 +129,13 @@ async fn interrupt_if_no_pending_input_handles_cancelled_submission() { .expect("active task") .cancellation_token .clone(); - let (tx_sub, rx_sub) = async_channel::bounded(/*cap*/ 1); - let thread = CodexThread::new( + let (thread, rx_sub) = make_thread_for_session( Arc::clone(&session), - SessionIo { - tx_sub, - rx_event, - agent_status: watch::channel(AgentStatus::PendingInit).1, - session_loop_termination: completed_session_loop_termination(), - }, - ThreadStartupMetadata::from(&SessionConfiguredEvent { - session_id: session.session_id(), - thread_id: session.thread_id(), - forked_from_id: None, - parent_thread_id: None, - thread_source: None, - thread_name: None, - model: "test".to_string(), - model_provider_id: "test".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: turn_context.permission_profile(), - active_permission_profile: None, - cwd: turn_context.config.cwd.clone(), - reasoning_effort: None, - network_proxy: None, - rollout_path: None, - }), - /*rollout_path*/ None, + &turn_context, + rx_event, SessionSource::Cli, - ); + ) + .await; thread.submit(Op::Interrupt).await.expect("fill the queue"); let interrupt = thread.interrupt_if_no_pending_input(&turn_context.sub_id); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5ded792689..128bc7d5bf 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -643,6 +643,10 @@ pub(super) async fn submission_loop( } false } + Op::ReplaceDynamicTools { tools, reply } => { + let _ = reply.send(sess.replace_dynamic_tools(tools).await); + false + } Op::TurnSettings { turn_id, update, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 90ec0c9318..bc2f4c24f5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -353,6 +353,7 @@ use codex_git_utils::get_git_repo_root; use codex_history::CodexHarnessMetadata; use codex_history::CompactedItem; use codex_history::CompactionResumeMetadata; +use codex_history::DynamicToolsSnapshot; use codex_history::InitialHistory; pub(crate) use codex_history::PreviousTurnSettings; use codex_history::ResponseItemEnvelope; @@ -845,8 +846,10 @@ impl Session { .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) .unwrap_or_else(|| render_model_instructions(&model_info)); - // Dynamic tools are defined at thread start and persisted in rollout session metadata. - let dynamic_tools = if dynamic_tools.is_empty() { + // A fork records its initial catalog in the new thread's creation metadata. + let dynamic_tools = if dynamic_tools.is_empty() + && matches!(conversation_history, InitialHistory::Forked(_)) + { conversation_history.get_dynamic_tools().unwrap_or_default() } else { dynamic_tools @@ -4073,6 +4076,7 @@ impl Session { settings = Arc::new(inherited_settings); } } + let dynamic_tools = self.dynamic_tools().await; let token_budget = token_budget::resolve_token_budget( turn_context.configured_token_budget.as_ref(), turn_context.use_model_token_budget_defaults, @@ -4200,6 +4204,7 @@ impl Session { &mcp, &extension_data, prepared_recommendations, + &dynamic_tools, ) .await?; Ok::<_, CodexErr>((executor_hook_sources, mcp, tool_router, extension_data)) @@ -4550,6 +4555,10 @@ impl Session { compaction_response_id: metadata.compaction_response_id, latest_token_usage_record: state.latest_token_usage_record.clone(), resume_metadata: Some(CompactionResumeMetadata { + dynamic_tools: Some(Box::new(DynamicToolsSnapshot { + thread_id: self.thread_id(), + tools: state.persisted_dynamic_tools.to_vec(), + })), multi_agent_version: self.multi_agent_version(), last_started_turn_id: state.last_started_turn_id.clone(), turn_attribution: state.turn_attribution.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 7c8ae88b5d..1205827911 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -242,7 +242,8 @@ impl ContextManager { .rev() .filter_map(|item| match item { RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => Some(event.turn_id.as_str()), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } @@ -439,6 +440,7 @@ impl ContextManager { active_segment.counts_as_user_turn = true; } RolloutItem::EventMsg(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SessionMeta(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) @@ -553,6 +555,7 @@ impl ContextManager { | RolloutItem::WorldState(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SessionMeta(_) => {} } } @@ -586,7 +589,8 @@ impl ContextManager { }; baseline.apply_merge_patch(&world_state.state); } - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 86430a174f..c102a49743 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -1622,6 +1622,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions( compaction_response_id: None, latest_token_usage_record: None, resume_metadata: current.then(|| codex_history::CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: None, last_started_turn_id: Some(format!("wake-{window_number}")), turn_attribution: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 59c926b9f8..f979ceef90 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -166,8 +166,8 @@ pub(crate) struct SessionConfiguration { pub(super) thread_source: Option, /// Effective originator used for this thread's Responses requests and analytics events. pub(super) originator: String, - /// Fixed at thread startup and shared with turn snapshots. - pub(super) dynamic_tools: Arc<[DynamicToolSpec]>, + /// Active tools used to build the next model request. + pub(crate) dynamic_tools: Arc<[DynamicToolSpec]>, pub(super) user_shell_override: Option, } @@ -707,12 +707,9 @@ impl Session { state.session_configuration.originator.clone() } - pub(crate) async fn dynamic_tools(&self) -> Vec { - let dynamic_tools = { - let state = self.state.lock().await; - Arc::clone(&state.session_configuration.dynamic_tools) - }; - dynamic_tools.to_vec() + pub(crate) async fn dynamic_tools(&self) -> Arc<[DynamicToolSpec]> { + let state = self.state.lock().await; + Arc::clone(&state.session_configuration.dynamic_tools) } pub(crate) async fn responses_metadata( @@ -1576,6 +1573,16 @@ impl Session { &config.features, ), ); + if matches!(initial_history, InitialHistory::Resumed(_)) { + state.persisted_dynamic_tools = initial_history + .get_dynamic_tools() + .unwrap_or_default() + .into(); + if state.session_configuration.dynamic_tools.is_empty() { + state.session_configuration.dynamic_tools = + Arc::clone(&state.persisted_dynamic_tools); + } + } state.base_instructions_provenance = base_instructions_provenance.clone(); // Restore a provided effort baseline before startup prewarm can establish a new one. if let Some(pin) = thread_extension_data.remove::() { @@ -1711,7 +1718,8 @@ impl Session { } } RolloutItem::EventMsg(event) => mcp_runtime.observe_event(event), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a8525d297d..579d109872 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -330,6 +330,8 @@ impl StepContext { } } +#[path = "dynamic_tools_tests.rs"] +mod dynamic_tools_tests; mod guardian_tests; fn user_message(text: &str) -> ResponseItem { @@ -1054,7 +1056,8 @@ async fn preview_session_start_hooks( ) } -pub(crate) fn tool_registry_for_test_step( +pub(crate) async fn tool_registry_for_test_step( + session: &Session, step_context: &StepContext, ) -> (ToolRegistry, Vec) { let mut registry = crate::tools::spec_plan::build_core_tool_registry( @@ -1071,14 +1074,18 @@ pub(crate) fn tool_registry_for_test_step( &mut registry, Vec::new(), Vec::new(), - &step_context.turn.dynamic_tools, + session.dynamic_tools().await.as_ref(), ); (registry, hosted_specs) } -fn test_tool_runtime(session: Arc, turn_context: Arc) -> ToolCallRuntime { +async fn test_tool_runtime( + session: Arc, + turn_context: Arc, +) -> ToolCallRuntime { let step_context = StepContext::for_test(Arc::clone(&turn_context)); - let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref()); + let (registry, hosted_specs) = + tool_registry_for_test_step(&session, step_context.as_ref()).await; let router = Arc::new(ToolRouter::from_registry( step_context.turn.as_ref(), step_context.turn.model_info(), @@ -4113,16 +4120,46 @@ async fn record_initial_history_reconstructs_forked_transcript() { ); } +#[test_case::test_case(false; "replacement")] +#[test_case::test_case(true; "cleared")] #[tokio::test] -async fn start_new_context_window_persists_checkpoint_state() { +async fn start_new_context_window_persists_checkpoint_state(clear_tools: bool) { let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), - |_| {}, + |config| config.allow_persistent_dynamic_tool_updates = true, ) .await; + let dynamic_tools = vec![codex_protocol::dynamic_tools::DynamicToolSpec::Function( + codex_protocol::dynamic_tools::DynamicToolFunctionSpec { + name: "check_release".into(), + description: "Check the release.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + )]; + let mut initial_tools = dynamic_tools.clone(); + if let codex_protocol::dynamic_tools::DynamicToolSpec::Function(tool) = &mut initial_tools[0] { + tool.name = "original_tool".into(); + } + session + .state + .lock() + .await + .session_configuration + .dynamic_tools = initial_tools.into(); let rollout_path = attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; + session + .replace_dynamic_tools(dynamic_tools.clone()) + .await + .unwrap(); + let dynamic_tools = if clear_tools { + session.replace_dynamic_tools(Vec::new()).await.unwrap(); + Vec::new() + } else { + dynamic_tools + }; let thread_id = ThreadId::new(); let token_usage_record = TokenUsageRecord { thread_id, @@ -4165,6 +4202,29 @@ async fn start_new_context_window_persists_checkpoint_state() { else { panic!("expected resumed rollout history"); }; + let checkpoint = resumed + .history + .iter() + .rposition(|item| matches!(item, RolloutItem::Compacted(_))) + .unwrap(); + assert_eq!( + codex_history::latest_dynamic_tools(&resumed.history[checkpoint..]), + Some(dynamic_tools.as_slice()) + ); + // A crash can leave only the compaction record, without its following companions. + let mut scan = codex_rollout::ModelContextScan::default(); + for item in resumed.history[..=checkpoint].iter().rev() { + if scan.push(item.clone()) == codex_rollout::ModelContextScanProgress::Complete { + break; + } + } + let mut recovered = scan.finish(); + assert!(matches!(recovered.as_slice(), [RolloutItem::Compacted(_)])); + recovered.insert(0, resumed.history[0].clone()); + assert_eq!( + codex_history::latest_dynamic_tools(&recovered), + Some(dynamic_tools.as_slice()) + ); let persisted_world_state = resumed .history .iter() @@ -4178,7 +4238,8 @@ async fn start_new_context_window_persists_checkpoint_state() { assert_eq!(persisted_world_state.state, expected_snapshot); let persisted_compacted = resumed.history.iter().rev().find_map(|item| match item { RolloutItem::Compacted(compacted) => Some(compacted), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } @@ -4278,7 +4339,8 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item }; let persisted_item = resumed.history.iter().find_map(|item| match item { RolloutItem::ResponseItem(response_item) => Some(response_item), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::Compacted(_) @@ -4999,7 +5061,7 @@ async fn open_thread_persistence(session: &mut Session) -> PathBuf { thread_source: None, originator: "test_originator".to_string(), base_instructions: BaseInstructions::default(), - dynamic_tools: Vec::new(), + dynamic_tools: session.dynamic_tools().await.to_vec(), selected_capability_roots: Vec::new(), multi_agent_version: None, history_mode: Default::default(), @@ -6299,6 +6361,10 @@ async fn compaction_persists_resume_metadata_and_companion_records() { session.multi_agent_version = std::sync::OnceLock::from(MultiAgentVersion::V2); let expected = CompactionResumeMetadata { + dynamic_tools: Some(Box::new(DynamicToolsSnapshot { + thread_id: session.thread_id(), + tools: Vec::new(), + })), multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: Some(turn_context.sub_id.clone()), turn_attribution: Some(codex_history::TurnAttribution { @@ -9455,6 +9521,49 @@ pub(crate) async fn make_session_and_context_with_rx() -> ( make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await } +// Wrap an existing session without starting a submission loop, so tests control its queue. +pub(super) async fn make_thread_for_session( + session: Arc, + turn_context: &TurnContext, + rx_event: async_channel::Receiver, + session_source: SessionSource, +) -> (crate::CodexThread, async_channel::Receiver) { + let rollout_path = session.current_rollout_path().await.expect("rollout path"); + let event = SessionConfiguredEvent { + session_id: session.session_id(), + thread_id: session.thread_id(), + forked_from_id: None, + parent_thread_id: None, + thread_source: None, + thread_name: None, + model: "test".into(), + model_provider_id: "test".into(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: turn_context.permission_profile(), + active_permission_profile: None, + cwd: turn_context.config.cwd.clone(), + reasoning_effort: None, + network_proxy: None, + rollout_path: rollout_path.clone(), + }; + let (tx_sub, rx_sub) = async_channel::bounded(/*cap*/ 1); + let thread = crate::CodexThread::new( + session, + SessionIo { + tx_sub, + rx_event, + agent_status: watch::channel(AgentStatus::PendingInit).1, + session_loop_termination: completed_session_loop_termination(), + }, + crate::ThreadStartupMetadata::from(&event), + rollout_path, + session_source, + ); + (thread, rx_sub) +} + #[tokio::test] async fn cancelled_step_capture_finishes_warning_delivery() { struct WarningProvider { @@ -13183,7 +13292,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { sess: Arc::clone(&sess), step_context: StepContext::for_test(Arc::clone(&tc)), turn_store: Arc::new(codex_extension_api::ExtensionData::new(tc.sub_id.clone())), - tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)), + tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)).await, defer_tool_dispatch: false, cancellation_token: CancellationToken::new(), }; @@ -13282,7 +13391,8 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() { async fn fatal_tool_error_stops_turn_and_reports_error() { let (session, turn_context, _rx) = make_session_and_context_with_rx().await; let step_context = StepContext::for_test(Arc::clone(&turn_context)); - let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref()); + let (registry, hosted_specs) = + tool_registry_for_test_step(&session, step_context.as_ref()).await; let router = ToolRouter::from_registry( step_context.turn.as_ref(), step_context.turn.model_info(), diff --git a/codex-rs/core/src/session/thread_settings.rs b/codex-rs/core/src/session/thread_settings.rs index f07d921dc2..c3a82eff0f 100644 --- a/codex-rs/core/src/session/thread_settings.rs +++ b/codex-rs/core/src/session/thread_settings.rs @@ -8,15 +8,54 @@ use crate::WithTurnExtensionData; use crate::config::ConstraintResult; use codex_history::RolloutItem; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::ThreadSettingsSnapshot; +use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; +use std::sync::Arc; use tokio::sync::SemaphorePermit; impl Session { + /// Persist before publishing so a failed write cannot change the running catalog. + pub(crate) async fn replace_dynamic_tools( + &self, + tools: Vec, + ) -> CodexResult<()> { + let _settings_guard = acquire_persistence_lock(self).await; + if let Some(live_thread) = self.live_thread() { + let config = self.get_config().await; + if !config.allow_persistent_dynamic_tool_updates { + return Err(CodexErr::UnsupportedOperation( + "The host has disabled dynamic tool updates for persistent threads.".into(), + )); + } + let persistence_error = |error: ThreadStoreError| match error { + ThreadStoreError::Unsupported { .. } => { + CodexErr::UnsupportedOperation(error.to_string()) + } + _ => CodexErr::Fatal(format!("failed to replace dynamic tools: {error}")), + }; + live_thread + .append_items(&[RolloutItem::DynamicToolsUpdated { + thread_id: self.thread_id(), + tools: tools.clone(), + }]) + .await + .map_err(persistence_error)?; + live_thread.flush().await.map_err(persistence_error)?; + } + let tools = Arc::from(tools); + let mut state = self.state.lock().await; + state.persisted_dynamic_tools = Arc::clone(&tools); + state.session_configuration.dynamic_tools = tools; + Ok(()) + } + /// Captures and flushes current settings under the shared persistence permit. pub(crate) async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> { let _settings_guard = acquire_persistence_lock(self).await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7020ad25a3..4c1395fa19 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1935,6 +1935,7 @@ pub(crate) async fn built_tools( mcp: &Arc, step_store: &ExtensionData, prepared_recommendations: PreparedToolRecommendations, + dynamic_tools: &[codex_protocol::dynamic_tools::DynamicToolSpec], ) -> CodexResult> { let all_mcp_tools = mcp.tools(); let connector_snapshot = mcp.config().connector_snapshot.clone(); @@ -2005,6 +2006,7 @@ pub(crate) async fn built_tools( apps_enabled, step_store, tool_suggest_candidates.as_ref(), + dynamic_tools, )?)) } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b31eeca5d5..a25dd60102 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -368,7 +368,6 @@ pub struct TurnContext { pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) available_models: Vec, pub(crate) final_output_json_schema: Option, - pub(crate) dynamic_tools: Arc<[DynamicToolSpec]>, pub(crate) turn_metadata_state: Arc, pub(crate) extension_data: Arc, pub(crate) turn_timing_state: Arc, @@ -452,7 +451,6 @@ impl TurnContext { #[allow(deprecated)] cwd: self.cwd.clone(), final_output_json_schema: None, - dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state, extension_data, turn_timing_state: Arc::new(TurnTimingState::default()), @@ -864,7 +862,6 @@ impl TurnContext { windows_sandbox_level: self.windows_sandbox_level, available_models, final_output_json_schema: self.final_output_json_schema.clone(), - dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), extension_data: Arc::clone(&self.extension_data), turn_timing_state: Arc::clone(&self.turn_timing_state), @@ -1149,7 +1146,6 @@ impl Session { windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, final_output_json_schema: None, - dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, extension_data, turn_timing_state: Arc::new(TurnTimingState::default()), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index d122db22d8..ebfc5817cb 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,11 +1,13 @@ //! Session-wide mutable state. +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructionsProvenance; #[cfg(test)] use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use std::collections::HashSet; use std::collections::VecDeque; +use std::sync::Arc; use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; @@ -68,6 +70,8 @@ impl ReasoningEffortPin { /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, + /// Catalog to carry across compaction, independent of an explicit resume override. + pub(crate) persisted_dynamic_tools: Arc<[DynamicToolSpec]>, /// Plugin selection of the last admitted task; settings updates take effect on the next task. pub(crate) active_disabled_plugin_ids: Vec, /// Persisted origin of the session base instructions, when known. @@ -125,6 +129,7 @@ impl SessionState { ) -> Self { Self { active_disabled_plugin_ids: Vec::new(), + persisted_dynamic_tools: Arc::clone(&session_configuration.dynamic_tools), session_configuration, base_instructions_provenance: None, history, diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index d402653316..a7b47e1370 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -295,9 +295,10 @@ async fn handle_non_tool_response_item_runs_turn_item_contributors_only_when_req assert_eq!(text, "hello world"); } -fn output_context(session: Arc, turn_context: Arc) -> HandleOutputCtx { +async fn output_context(session: Arc, turn_context: Arc) -> HandleOutputCtx { let step_context = StepContext::for_test(Arc::clone(&turn_context)); - let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref()); + let (registry, hosted_specs) = + tool_registry_for_test_step(&session, step_context.as_ref()).await; let router = Arc::new(ToolRouter::from_registry( step_context.turn.as_ref(), step_context.turn.model_info(), @@ -325,7 +326,7 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() { let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); session.services.extensions = Arc::new(builder.build()); - let mut ctx = output_context(Arc::new(session), Arc::new(turn_context)); + let mut ctx = output_context(Arc::new(session), Arc::new(turn_context)).await; let item = assistant_output_text("original assistant text"); let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None) @@ -352,7 +353,7 @@ async fn direct_results_keep_their_own_records_when_call_ids_repeat() { }, ) .await; - let mut ctx = output_context(Arc::clone(&session), turn_context); + let mut ctx = output_context(Arc::clone(&session), turn_context).await; let mut pending = Vec::new(); for step in ["read", "write"] { let arguments = json!({"plan": [{"step": step, "status": "in_progress"}]}); diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index aff6d41660..498fe97057 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -164,14 +164,14 @@ fn test_tool_router( #[tokio::test] async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow::Result<()> { - let (_, turn) = make_session_and_context().await; + let (session, turn) = make_session_and_context().await; let turn = Arc::new(turn); let step_context = StepContext::for_test(Arc::clone(&turn)); let router = test_tool_router( step_context.as_ref(), Vec::new(), Vec::new(), - &turn.dynamic_tools, + session.dynamic_tools().await.as_ref(), ); let parallel_tool_name = ["exec_command"] @@ -313,7 +313,7 @@ fn build_tool_call_normalizes_default_function_and_custom_namespaces() -> anyhow #[tokio::test] async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { - let (_, turn) = make_session_and_context().await; + let (session, turn) = make_session_and_context().await; let turn = Arc::new(turn); let step_context = StepContext::for_test(Arc::clone(&turn)); let router = test_tool_router( @@ -354,7 +354,7 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { }, ], Vec::new(), - &turn.dynamic_tools, + session.dynamic_tools().await.as_ref(), ); let call = ToolCall { @@ -415,14 +415,14 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { #[tokio::test] async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> { - let (_, turn) = make_session_and_context().await; + let (session, turn) = make_session_and_context().await; let turn = Arc::new(turn); let step_context = StepContext::for_test(Arc::clone(&turn)); let router = test_tool_router( step_context.as_ref(), Vec::new(), Vec::new(), - &turn.dynamic_tools, + session.dynamic_tools().await.as_ref(), ); assert!(!router.tool_supports_parallel(&ToolCall { @@ -573,7 +573,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow &session, &codex_extension_api::ExtensionData::new(turn.sub_id.clone()), ), - &turn.dynamic_tools, + session.dynamic_tools().await.as_ref(), ); assert!( diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 192deff7e1..2602fcb9bb 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -127,6 +127,7 @@ pub(crate) fn build_tool_router( apps_enabled: bool, step_store: &ExtensionData, tool_suggest_candidates: Option<&crate::tools::router::ToolSuggestCandidates>, + dynamic_tools: &[DynamicToolSpec], ) -> CodexResult { let default_agent_type_description = crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); @@ -170,7 +171,7 @@ pub(crate) fn build_tool_router( extension_tool_executors(session, step_store), &mut registry, ); - append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry); + append_dynamic_tool_runtimes(dynamic_tools, &mut registry); let hosted_specs = hosted_model_tool_specs( turn_context, model_info, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1ecd9f77f1..58351c7cac 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -601,6 +601,7 @@ async fn reviewer_tool_policy_exclude_optional_core_tools() { /*apps_enabled*/ false, &turn.extension_data, /*tool_suggest_candidates*/ None, + session.dynamic_tools().await.as_ref(), ) .expect("build internal Guardian tool router"); @@ -653,6 +654,7 @@ async fn reviewer_tool_policy_respect_managed_shell_restrictions() { /*apps_enabled*/ false, &turn.extension_data, /*tool_suggest_candidates*/ None, + session.dynamic_tools().await.as_ref(), ) .expect("build internal Guardian tool router"); @@ -689,6 +691,7 @@ async fn reviewer_tool_policy_preserve_code_mode() { /*apps_enabled*/ false, &turn.extension_data, /*tool_suggest_candidates*/ None, + session.dynamic_tools().await.as_ref(), ) .expect("build internal Guardian tool router"); @@ -759,6 +762,7 @@ async fn reviewer_tool_policy_require_managed_secondary_environments() { /*apps_enabled*/ false, &turn.extension_data, /*tool_suggest_candidates*/ None, + session.dynamic_tools().await.as_ref(), ) .expect("build internal Guardian tool router"); diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index ff0c226da5..b7e8c56a8b 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -102,6 +102,9 @@ use tokio::sync::oneshot; const ONE_PIXEL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +#[path = "scenarios_dynamic_tools.rs"] +mod dynamic_tools; + #[path = "scenarios_retained_tool_outputs.rs"] mod retained_tool_outputs; diff --git a/codex-rs/core/tests/suite/scenarios_dynamic_tools.rs b/codex-rs/core/tests/suite/scenarios_dynamic_tools.rs new file mode 100644 index 0000000000..38ececca0d --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_dynamic_tools.rs @@ -0,0 +1,261 @@ +//! Dynamic tool replacement takes effect between model requests, including after restart. + +use super::*; +use core_test_support::responses; +use pretty_assertions::assert_eq; + +fn configure_dynamic_tool_scenario(config: &mut Config) { + config.model = Some("gpt-6-astra".into()); + configure_scenario_catalog(config); + config.include_environment_context = false; + config + .features + .disable(Feature::CodeMode) + .expect("disable code mode"); + let model = config + .model_catalog + .as_mut() + .expect("scenario model catalog") + .models + .iter_mut() + .find(|model| model.slug == "gpt-6-astra") + .expect("Astra model"); + model.use_responses_lite = false; + model.tool_mode = Some(ToolMode::Direct); +} + +fn tool(name: &str) -> DynamicToolSpec { + DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: name.to_string(), + description: format!("Check {name}."), + input_schema: json!({"type": "object", "properties": {}, "additionalProperties": false}), + defer_loading: false, + }) +} + +fn has_tool(body: serde_json::Value, name: &str) -> bool { + body["tools"] + .as_array() + .unwrap_or_else(|| panic!("request has no tools: {body}")) + .iter() + .any(|tool| tool["name"] == name) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dynamic_tools_update_preserves_pending_calls_and_survives_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let enable_updates = |config: &mut Config| config.allow_persistent_dynamic_tool_updates = true; + let mut builder = test_codex() + .with_config(configure_dynamic_tool_scenario) + .with_config(enable_updates); + let mut test = builder.build_with_auto_env(&server).await?; + let first = responses::mount_sse_once( + &server, + sse(vec![ + responses::ev_function_call("release-call", "release", "{}"), + ev_completed("first"), + ]), + ) + .await; + let (reply, updated) = tokio::sync::oneshot::channel(); + test.codex + .submit(Op::ReplaceDynamicTools { + tools: vec![tool("release")], + reply, + }) + .await?; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![text( + "Check the release and deployment.", + )])) + .await?; + updated.await??; + let call = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(call) => Some(call.clone()), + _ => None, + }) + .await; + assert_eq!(call.tool, "release"); + test.codex + .replace_dynamic_tools(vec![tool("deployment")]) + .await?; + let second = responses::mount_sse_once( + &server, + sse(vec![ + responses::ev_function_call("deployment-call", "deployment", "{}"), + ev_completed("second"), + ]), + ) + .await; + test.codex + .submit(Op::DynamicToolResponse { + id: call.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "Release ready.".into(), + }], + success: true, + }, + }) + .await?; + let call = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(call) => Some(call.clone()), + _ => None, + }) + .await; + assert_eq!(call.tool, "deployment"); + let third = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("ready", "Both checks passed."), + ev_completed("third"), + ]), + ) + .await; + test.codex + .submit(Op::DynamicToolResponse { + id: call.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "Deployment ready.".into(), + }], + success: true, + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert!(has_tool(first.single_request().body_json(), "release")); + assert!(!has_tool(second.single_request().body_json(), "release")); + assert!(has_tool(second.single_request().body_json(), "deployment")); + assert_eq!( + second + .single_request() + .function_call_output_content_and_success("release-call"), + Some((Some("Release ready.".into()), None)) + ); + assert_eq!( + third + .single_request() + .function_call_output_content_and_success("deployment-call"), + Some((Some("Deployment ready.".into()), None)) + ); + + builder = builder.with_config(configure_dynamic_tool_scenario); + test = builder.restart_with_auto_env(&server, &test).await?; + let restored = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("restored", "The deployment tool is available."), + ev_completed("restored"), + ]), + ) + .await; + test.submit_text_turn("Which check is available now?") + .await?; + assert!(has_tool( + restored.single_request().body_json(), + "deployment" + )); + assert!(!has_tool(restored.single_request().body_json(), "release")); + + builder = builder + .with_config(configure_dynamic_tool_scenario) + .with_config(enable_updates); + test = builder.restart_with_auto_env(&server, &test).await?; + test.codex.replace_dynamic_tools(Vec::new()).await?; + builder = builder.with_config(configure_dynamic_tool_scenario); + test = builder.restart_with_auto_env(&server, &test).await?; + let restarted = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("done", "The checks are complete."), + ev_completed("restarted"), + ]), + ) + .await; + test.submit_text_turn("Summarize the checks.").await?; + assert!(!has_tool(restarted.single_request().body_json(), "release")); + assert!(!has_tool( + restarted.single_request().body_json(), + "deployment" + )); + let requests = responses::received_responses_requests(&server).await; + insta::assert_snapshot!(context_snapshot::format_request_history_snapshot( + "A pending release call survives replacement with deployment. The next step uses deployment. Replacement and clearing each survive restart.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + )); + test.codex.shutdown_and_wait().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dynamic_tools_resume_override_stays_local_after_compaction() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let test = test_codex() + .with_config(configure_dynamic_tool_scenario) + .with_config(|config| { + config.features.enable(Feature::TokenBudget).unwrap(); + }) + .build_with_auto_env(&server) + .await?; + let start = async |initial_history, dynamic_tools| { + test.thread_manager + .start_thread(StartThreadOptions { + initial_history, + dynamic_tools, + history_mode: Some(codex_protocol::protocol::ThreadHistoryMode::Legacy), + ..test.start_thread_options().await + }) + .await + }; + let initial = start(codex_history::InitialHistory::New, vec![tool("release")]).await?; + initial.thread.ensure_rollout_materialized().await; + initial.thread.shutdown_and_wait().await?; + let path = initial.thread.rollout_path().expect("rollout path"); + let load_history = || codex_rollout::RolloutRecorder::get_rollout_history(&path); + let check_tools = async |thread: &codex_core::CodexThread, present: &str, absent: &str| { + let response = responses::mount_sse_once(&server, responses::sse_completed(present)).await; + thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![text( + "Check the release.", + )])) + .await?; + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let request = response.single_request(); + assert!(has_tool(request.body_json(), present)); + assert!(!has_tool(request.body_json(), absent)); + Ok::<_, anyhow::Error>(()) + }; + + let overridden = start(load_history().await?, vec![tool("deployment")]) + .await? + .thread; + check_tools(&overridden, "deployment", "release").await?; + overridden.submit(Op::Compact).await?; + wait_for_event(&overridden, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + overridden.shutdown_and_wait().await?; + + let history = load_history().await?; + assert!( + history + .get_rollout_items() + .iter() + .any(|item| matches!(item, codex_history::RolloutItem::Compacted(_))), + "resume must read the persisted compaction checkpoint" + ); + let restored = start(history, Vec::new()).await?.thread; + check_tools(&restored, "release", "deployment").await?; + restored.shutdown_and_wait().await?; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__dynamic_tools__dynamic_tools_update_preserves_pending_calls_and_survives_resume.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__dynamic_tools__dynamic_tools_update_preserves_pending_calls_and_survives_resume.snap new file mode 100644 index 0000000000..cdbd3f8b2e --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__dynamic_tools__dynamic_tools_update_preserves_pending_calls_and_survives_resume.snap @@ -0,0 +1,112 @@ +--- +source: core/tests/suite/scenarios_dynamic_tools.rs +assertion_line: 173 +expression: "context_snapshot::format_request_history_snapshot(\"A pending release call survives replacement with deployment. The next step uses deployment. Replacement and clearing each survive restart.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: A pending release call survives replacement with deployment. The next step uses deployment. Replacement and clearing each survive restart. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-6-astra" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"low"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (10; hash=39aa00a8736bb135): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - function/request_user_input_async: Ask the user one or more questions during ongoing work. Use this tool only to request missing inf...; args=[questions]; hash=d6d74f33db330162 + - namespace/clock: Tools for reading and waiting on time.; hash=a80435c7a3a3943f + - function/curr_time + - function/sleep + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent + - function/release: Check release.; args=[]; hash=4c6f91a1a21d2a65 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] +02:message/developer: + +03:message/developer: + +04:message/user: + Check the release and deployment. +05:configuration_update:reasoning={"effort":"low"} + +## Window 2 (after request 1) +Settings: relative to window 1 + tools (10; hash=f9e31c2361b9bc7d): + - removed function/release + + function/deployment: Check deployment.; args=[]; hash=2e9a71e9a20e43ad +-- request 2 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] +02:message/developer: + +03:message/developer: + +04:message/user: + Check the release and deployment. +05:configuration_update:reasoning={"effort":"low"} +06:function_call/release:{} +07:function_call_output:Release ready. +-- request 3 (turn) -- +08:function_call/deployment:{} +09:function_call_output:Deployment ready. +-- request 4 (turn) -- +10:message/assistant: + Both checks passed. +11:message/user: + Which check is available now? +12:configuration_update:reasoning={"effort":"low"} + +## Window 3 (after request 4) +Settings: relative to window 2 + tools (9; hash=4f2e62909d4cf2eb): + - removed function/deployment +-- request 5 (turn) -- +00:message/developer: + +01:message/developer[2]: + [01] + [02] +02:message/developer: + +03:message/developer: + +04:message/user: + Check the release and deployment. +05:configuration_update:reasoning={"effort":"low"} +06:function_call/release:{} +07:function_call_output:Release ready. +08:function_call/deployment:{} +09:function_call_output:Deployment ready. +10:message/assistant: + Both checks passed. +11:message/user: + Which check is available now? +12:configuration_update:reasoning={"effort":"low"} +13:message/assistant: + The deployment tool is available. +14:message/user: + Summarize the checks. +15:configuration_update:reasoning={"effort":"low"} diff --git a/codex-rs/external-agent-migration/src/sessions/append.rs b/codex-rs/external-agent-migration/src/sessions/append.rs index b77481afd3..61f329cc19 100644 --- a/codex-rs/external-agent-migration/src/sessions/append.rs +++ b/codex-rs/external-agent-migration/src/sessions/append.rs @@ -246,6 +246,7 @@ fn source_model_items(items: &[RolloutItem]) -> Option>> | RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::WorldState(_) => return None, RolloutItem::EventMsg(_) => {} @@ -263,6 +264,7 @@ fn history_model_items(items: &[RolloutItem]) -> Option> { | RolloutItem::TokenUsageRecord(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) => {} RolloutItem::ResponseItem(response_item) => model_items.push(&response_item.item), RolloutItem::EventMsg( diff --git a/codex-rs/history/src/compaction_resume_metadata.rs b/codex-rs/history/src/compaction_resume_metadata.rs index bd8eb7a267..ca22476e1a 100644 --- a/codex-rs/history/src/compaction_resume_metadata.rs +++ b/codex-rs/history/src/compaction_resume_metadata.rs @@ -1,6 +1,8 @@ //! Defines resume metadata stored directly on a compaction. use crate::RolloutItem; +use codex_protocol::ThreadId; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::turn_input::CyberAccessProgram; use codex_protocol::turn_input::TurnAttribution; @@ -11,9 +13,12 @@ use serde::Serialize; /// Resume metadata that is not represented by the companion records after a compaction. /// /// Presence distinguishes compactions that explicitly persisted these values from older -/// compactions that did not. Each field is authoritative, including an intentionally absent value. +/// compactions that did not. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CompactionResumeMetadata { + /// Catalog owned by the producing thread; absent in older checkpoints. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_tools: Option>, /// Selected runtime, including when the companion turn context is absent. pub multi_agent_version: Option, /// Turn identity used to admit continuations after cold resume. @@ -24,6 +29,13 @@ pub struct CompactionResumeMetadata { pub previous_turn_settings: Option, } +/// A thread's dynamic-tool catalog, including an empty catalog after a clear. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DynamicToolsSnapshot { + pub thread_id: ThreadId, + pub tools: Vec, +} + /// Previous user-turn settings used to reconstruct context changes after resume. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] pub struct PreviousTurnSettings { @@ -43,7 +55,8 @@ pub fn resume_multi_agent_version(item: &RolloutItem) -> Option for ResponseItemEnvelope { #[derive(Debug, Clone)] pub enum RolloutItem { SessionMeta(Box), + /// Complete dynamic-tool catalog for subsequent model steps, including an empty catalog. + DynamicToolsUpdated { + thread_id: ThreadId, + tools: Vec, + }, ResponseItem(ResponseItemEnvelope), InterAgentCommunication(InterAgentCommunication), InterAgentCommunicationMetadata { @@ -446,17 +452,7 @@ impl InitialHistory { } pub fn get_dynamic_tools(&self) -> Option> { - match self { - Self::New | Self::Cleared => None, - Self::Resumed(resumed) => resumed.history.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(), - _ => None, - }), - Self::Forked(items) => items.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(), - _ => None, - }), - } + latest_dynamic_tools(self.get_rollout_items()).map(<[_]>::to_vec) } pub fn get_selected_capability_roots(&self) -> Vec { @@ -538,6 +534,31 @@ fn session_cwd_from_items(items: &[RolloutItem]) -> Option { }) } +/// Returns the latest catalog, preserving explicit clears and falling back to creation metadata. +pub fn latest_dynamic_tools(items: &[RolloutItem]) -> Option<&[DynamicToolSpec]> { + let metadata = items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta) => Some(&meta.meta), + _ => None, + }); + items + .iter() + .rev() + .find_map(|item| { + let (thread_id, tools) = match item { + RolloutItem::DynamicToolsUpdated { thread_id, tools } => (thread_id, tools), + RolloutItem::Compacted(compacted) => { + let snapshot = compacted.resume_metadata.as_ref()?.dynamic_tools.as_ref()?; + (&snapshot.thread_id, &snapshot.tools) + } + _ => return None, + }; + metadata + .is_none_or(|meta| meta.id == *thread_id) + .then_some(tools.as_slice()) + }) + .or_else(|| metadata.and_then(|meta| meta.dynamic_tools.as_deref())) +} + /// Returns a thread's latest plugin selection, with a turn-context fallback. /// /// Forked history may contain ancestor snapshots, and compaction may append a diff --git a/codex-rs/history/src/rollout_payload.rs b/codex-rs/history/src/rollout_payload.rs index 370e8c8c82..301c7d3c06 100644 --- a/codex-rs/history/src/rollout_payload.rs +++ b/codex-rs/history/src/rollout_payload.rs @@ -34,6 +34,9 @@ pub(super) enum RolloutItemWire<'a> { SessionMeta { payload: Cow<'a, SessionMetaLine>, }, + DynamicToolsUpdated { + payload: DynamicToolsUpdatedPayload<'a>, + }, ResponseItem { payload: Cow<'a, ResponseItem>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -71,12 +74,24 @@ pub(super) enum RolloutItemWire<'a> { }, } +#[derive(Serialize, Deserialize, JsonSchema)] +pub(super) struct DynamicToolsUpdatedPayload<'a> { + thread_id: codex_protocol::ThreadId, + tools: Cow<'a, [codex_protocol::dynamic_tools::DynamicToolSpec]>, +} + impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> { fn from(item: &'a RolloutItem) -> Self { match item { RolloutItem::SessionMeta(payload) => Self::SessionMeta { payload: Cow::Borrowed(payload), }, + RolloutItem::DynamicToolsUpdated { thread_id, tools } => Self::DynamicToolsUpdated { + payload: DynamicToolsUpdatedPayload { + thread_id: *thread_id, + tools: Cow::Borrowed(tools), + }, + }, RolloutItem::ResponseItem(envelope) => Self::ResponseItem { payload: Cow::Borrowed(&envelope.item), metadata: envelope.metadata.as_ref().map(Cow::Borrowed), @@ -169,6 +184,10 @@ impl From> for RolloutItem { RolloutItemWire::SessionMeta { payload } => { Self::SessionMeta(Box::new(payload.into_owned())) } + RolloutItemWire::DynamicToolsUpdated { payload } => Self::DynamicToolsUpdated { + thread_id: payload.thread_id, + tools: payload.tools.into_owned(), + }, RolloutItemWire::ResponseItem { payload, metadata } => { if let ( ResponseItem::Message { diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 2f6424b6b0..272de15d87 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -584,6 +584,7 @@ fn compacted_replacement_history_stores_metadata_in_an_aligned_sidecar() -> Resu #[test] fn compacted_resume_metadata_presence_round_trips_empty_values() -> Result<()> { let resume_metadata = CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: None, last_started_turn_id: None, turn_attribution: None, @@ -695,6 +696,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> compaction_response_id: None, latest_token_usage_record: None, resume_metadata: Some(CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: Some("turn-1".to_string()), turn_attribution: None, @@ -848,7 +850,7 @@ fn rollout_item_variants_preserve_existing_payload_shapes() -> Result<()> { fn rollout_item_schema_matches_tagged_payload_and_sibling_metadata() -> Result<()> { let schema = serde_json::to_value(schemars::schema_for!(RolloutItem))?; let variants = schema["oneOf"].as_array().expect("rollout variants"); - assert_eq!(variants.len(), 12); + assert_eq!(variants.len(), 13); for variant in variants { let required = variant["required"].as_array().expect("required fields"); @@ -1051,3 +1053,81 @@ fn multi_agent_version_uses_compaction_metadata_without_turn_context() -> Result ); Ok(()) } + +#[test] +fn dynamic_tools_updates_survive_replay_and_explicit_clear() -> Result<()> { + let tool = codex_protocol::dynamic_tools::DynamicToolSpec::Function( + codex_protocol::dynamic_tools::DynamicToolFunctionSpec { + name: "check_release".into(), + description: "Check the release.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + ); + let thread_id = ThreadId::new(); + let mut items = vec![RolloutItem::SessionMeta(Box::new(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + dynamic_tools: Some(vec![tool.clone()]), + ..SessionMeta::default() + }, + git: None, + }))]; + let mut replacement = tool.clone(); + if let codex_protocol::dynamic_tools::DynamicToolSpec::Function(tool) = &mut replacement { + tool.name = "check_deployment".into(); + } + let checkpoint = |thread_id: ThreadId, tools: Vec| -> Result { + Ok(RolloutItem::Compacted(Box::new(serde_json::from_value( + json!({ + "message": "summary", + "replacement_history": [], + "window_number": 1, + "resume_metadata": { + "dynamic_tools": {"thread_id": thread_id, "tools": tools} + } + }), + )?))) + }; + items.push(checkpoint(ThreadId::new(), vec![replacement.clone()])?); + assert_eq!( + latest_dynamic_tools(&items), + Some(std::slice::from_ref(&tool)) + ); + for expected in [vec![replacement], Vec::new()] { + for item in [ + checkpoint(thread_id, expected.clone())?, + RolloutItem::DynamicToolsUpdated { + thread_id, + tools: expected.clone(), + }, + ] { + items.push(checkpoint(thread_id, vec![tool.clone()])?); + items.push(item); + // Metadata-only writes and inherited records cannot resurrect a replaced catalog. + items.push(items[0].clone()); + items.push(RolloutItem::DynamicToolsUpdated { + thread_id: ThreadId::new(), + tools: vec![tool.clone()], + }); + items.push(checkpoint(ThreadId::new(), vec![tool.clone()])?); + let serialized = serde_json::to_vec(&items)?; + let replayed: Vec = serde_json::from_slice(&serialized)?; + assert_eq!( + InitialHistory::Forked(replayed.clone()).get_dynamic_tools(), + Some(expected.clone()) + ); + assert_eq!( + InitialHistory::Resumed(ResumedHistory { + history_revision: None, + conversation_id: thread_id, + history: Arc::new(replayed), + rollout_path: None, + }) + .get_dynamic_tools(), + Some(expected.clone()) + ); + } + } + Ok(()) +} diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index cddb20dadc..f5a5b42044 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -405,7 +405,8 @@ mod job { RolloutItem::InterAgentCommunication(communication) => { Some(communication.to_model_input_item()) } - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) diff --git a/codex-rs/memories/write/src/rollout_input.rs b/codex-rs/memories/write/src/rollout_input.rs index e0f9e643d1..fa86e25305 100644 --- a/codex-rs/memories/write/src/rollout_input.rs +++ b/codex-rs/memories/write/src/rollout_input.rs @@ -112,7 +112,8 @@ pub(crate) fn serialize_tiered_input( let item = match item { RolloutItem::ResponseItem(item) => sanitize_response_item_for_memories(&item.item), RolloutItem::InterAgentCommunication(message) => Some(message.to_model_input_item()), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 696549842a..1e3efd5da3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -778,6 +778,13 @@ pub enum Op { reply: Option>>, }, + /// Replace the dynamic tool catalog without starting a turn. + #[debug("ReplaceDynamicTools")] + ReplaceDynamicTools { + tools: Vec, + reply: oneshot::Sender>, + }, + /// Update only the named running turn, without changing future settings. /// The reply reports the actual publication or why it did not occur. #[debug("TurnSettings {{ turn_id: {turn_id:?}, requested_approvals_reviewer: {:?} }}", update.approvals_reviewer)] @@ -1096,6 +1103,7 @@ impl Op { Self::RecoverTurn { .. } => "recover_turn", Self::SuspendTurnAndShutdown { .. } => "suspend_turn_and_shutdown", Self::ThreadSettings { .. } => "thread_settings", + Self::ReplaceDynamicTools { .. } => "replace_dynamic_tools", Self::TurnSettings { .. } => "turn_settings", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::ExecApproval { .. } => "exec_approval", diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 4742dd864a..22d1399840 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1059,6 +1059,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { // Not included in `head`; skip. } @@ -1138,6 +1139,7 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result {} } @@ -1232,6 +1234,7 @@ pub async fn read_session_meta_line(path: &Path) -> io::Result | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => {} } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 93f967217b..21becec430 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -84,6 +84,7 @@ pub fn builder_from_items( | RolloutItem::RealtimeItem(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => None, }) && let Some(builder) = builder_from_session_meta(session_meta, rollout_path) @@ -181,6 +182,7 @@ pub async fn extract_metadata_from_rollout( | RolloutItem::RealtimeItem(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => None, }), diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs index bea161d9df..2566567b8b 100644 --- a/codex-rs/rollout/src/persistence_metrics.rs +++ b/codex-rs/rollout/src/persistence_metrics.rs @@ -253,6 +253,7 @@ impl Write for CountingWriter { fn rollout_item_type(item: &RolloutItem) -> String { match item { + RolloutItem::DynamicToolsUpdated { .. } => "dynamic_tools_updated".to_string(), RolloutItem::SessionMeta(_) => "session_meta".to_string(), RolloutItem::ResponseItem(item) => response_item_type(item).to_string(), RolloutItem::InterAgentCommunication(_) => "inter_agent_communication".to_string(), diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 93a1e3a5d8..b49049f6e9 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -45,6 +45,7 @@ pub fn persisted_rollout_item( | RolloutItem::WorldState(_) | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SessionMeta(_) => Some(Cow::Borrowed(item)), } } diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 6d6756e810..7298c41888 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -2260,7 +2260,8 @@ async fn resume_candidate_matches_cwd( if let Ok((items, _, _)) = RolloutRecorder::load_rollout_items(rollout_path).await && let Some(latest_turn_context_cwd) = items.iter().rev().find_map(|item| match item { RolloutItem::TurnContext(turn_context) => Some(&turn_context.cwd), - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::InterAgentCommunicationMetadata { .. } diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 439271830a..9f782c7e59 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -335,7 +335,8 @@ fn conversation_text_from_item(item: &RolloutItem) -> Option { Some(text) } } - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) | RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index a501301553..80ce0e086a 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -39,7 +39,9 @@ pub fn apply_rollout_item( | RolloutItem::InterAgentCommunicationMetadata { .. } => {} RolloutItem::Compacted(_) => {} RolloutItem::WorldState(_) => {} - RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => {} + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::RetainedContext(_) + | RolloutItem::SecurityRiskScore(_) => {} RolloutItem::RealtimeItem(_) => {} RolloutItem::TokenUsageRecord(_) => {} } @@ -73,6 +75,7 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { | RolloutItem::Compacted(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::WorldState(_) => false, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c72a9222f7..59f49001af 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1404,6 +1404,7 @@ pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::EventMsg(_) => None, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 56deb71fbc..1ced86ef11 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -346,6 +346,7 @@ async fn new_config( codex_home, history: History::default(), ephemeral: true, + allow_persistent_dynamic_tool_updates: false, extra_config: None, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: arg0_paths.codex_self_exe, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index 25fca4e638..d3feb44f2d 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -273,6 +273,7 @@ async fn fork_version_stops_before_older_segments_once_resolved() { unreachable!(); }; compacted.resume_metadata = Some(codex_rollout::CompactionResumeMetadata { + dynamic_tools: None, multi_agent_version: Some(MultiAgentVersion::V2), last_started_turn_id: None, turn_attribution: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs index 6a8ce712ea..5d7f336938 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs @@ -290,6 +290,7 @@ impl LegacyRolloutCanonicalizer { } item @ (RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::TurnContext(_) + | RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::TokenUsageRecord(_) | RolloutItem::RealtimeItem(_) | RolloutItem::RetainedContext(_) diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs index 1a884e8350..c7a6b3a0ea 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs @@ -159,7 +159,9 @@ impl RollbackPlanner { self.pending_delivery_boundary = None; match &line.item { - RolloutItem::SessionMeta(_) => self.record_boundaries[index] = None, + RolloutItem::DynamicToolsUpdated { .. } | RolloutItem::SessionMeta(_) => { + self.record_boundaries[index] = None + } RolloutItem::ResponseItem(response) => { if matches!(&response.item, ResponseItem::Message { role, .. } if role == "assistant") || matches!(&response.item, ResponseItem::FunctionCall { .. }) diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs index ac23e9c577..92a0c6c0b2 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs @@ -71,7 +71,8 @@ impl ModelReplayPlanner { RolloutItem::ResponseItem(response) if rollback::counts_as_boundary(&response.item) => { ReplayRecord::UserBoundary } - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::EventMsg(_) | RolloutItem::InterAgentCommunicationMetadata { .. } diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 7a75565a68..b81862a4a9 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -338,7 +338,8 @@ impl ThreadMetadataSync { update.approval_mode = Some(settings.approval_policy); update.permission_profile = Some(settings.permission_profile.clone()); } - RolloutItem::SessionMeta(_) + RolloutItem::DynamicToolsUpdated { .. } + | RolloutItem::SessionMeta(_) | RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_)