diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ae38a4241b..fe60a26945 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -258,7 +258,9 @@ use crate::mentions::collect_explicit_app_ids; use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; use crate::network_policy_decision::execpolicy_network_rule_amendment; +use crate::plugins::ExplicitPluginInstructionsContext; use crate::plugins::PluginsManager; +use crate::plugins::build_explicit_plugin_instructions_context; use crate::plugins::build_plugin_developer_sections; use crate::plugins::render_plugins_section; use crate::project_doc::get_user_instructions; @@ -2570,12 +2572,14 @@ impl Session { .await } - /// `user_input` is only consulted for explicit plugin mentions on this turn. + /// `explicit_plugin_instructions` carries the already-resolved turn-local plugin mention + /// context from the current user input so this builder can render that guidance without + /// re-listing MCP tools. async fn build_settings_update_items( &self, reference_context_item: Option<&TurnContextItem>, current_context: &TurnContext, - user_input: &[UserInput], + explicit_plugin_instructions: &ExplicitPluginInstructionsContext, ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible @@ -2587,9 +2591,6 @@ impl Session { }; let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); - let additional_developer_sections = self - .build_explicit_plugin_developer_sections(current_context, user_input) - .await; crate::context_manager::updates::build_settings_update_items( reference_context_item, previous_turn_settings.as_ref(), @@ -2597,48 +2598,10 @@ impl Session { shell.as_ref(), exec_policy.as_ref(), self.features.enabled(Feature::Personality), - additional_developer_sections, + explicit_plugin_instructions, ) } - async fn build_explicit_plugin_developer_sections( - &self, - turn_context: &TurnContext, - user_input: &[UserInput], - ) -> Vec { - if user_input.is_empty() { - return Vec::new(); - } - - let loaded_plugins = self - .services - .plugins_manager - .plugins_for_config(&turn_context.config); - let mentioned_plugins = - collect_explicit_plugin_mentions(user_input, loaded_plugins.capability_summaries()); - if mentioned_plugins.is_empty() { - return Vec::new(); - } - - let mcp_tools = self - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let available_connectors = if turn_context.apps_enabled() { - let connectors = connectors::merge_plugin_apps_with_accessible( - loaded_plugins.effective_apps(), - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - ); - connectors::with_app_enabled_state(connectors, &turn_context.config) - } else { - Vec::new() - }; - build_plugin_developer_sections(&mentioned_plugins, &mcp_tools, &available_connectors) - } - /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); @@ -3475,13 +3438,14 @@ impl Session { } } - /// `maybe_user_input` is only consulted for explicit plugin mentions on this turn. - /// Pass `None` when no raw current-turn user input is available, such as compaction callers - /// that are rebuilding prompt context from flattened history alone. + /// `explicit_plugin_instructions` is optional because compaction/rebuild callers do not have + /// the raw current-turn user input needed to resolve explicit plugin mentions. When present, + /// it carries the already-resolved plugin/tool/app context for turn-local plugin guidance in + /// the canonical developer envelope. pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, - maybe_user_input: Option<&[UserInput]>, + explicit_plugin_instructions: Option<&ExplicitPluginInstructionsContext>, ) -> Vec { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); @@ -3602,11 +3566,10 @@ impl Session { { developer_sections.push(plugin_section); } - if let Some(user_input) = maybe_user_input { - developer_sections.extend( - self.build_explicit_plugin_developer_sections(turn_context, user_input) - .await, - ); + if let Some(explicit_plugin_instructions) = explicit_plugin_instructions { + developer_sections.extend(build_plugin_developer_sections( + explicit_plugin_instructions, + )); } if turn_context.features.enabled(Feature::CodexGitCommit) && let Some(commit_message_instruction) = commit_message_trailer_instruction( @@ -3696,26 +3659,27 @@ impl Session { /// reinjects full initial context into replacement history. Other non-regular tasks /// intentionally do not update the baseline. /// - /// `user_input` is only used to derive explicit plugin-mention guidance for the canonical - /// developer envelope on this turn. + /// `explicit_plugin_instructions` carries the current turn's already-resolved explicit + /// plugin-mention context so whichever canonical builder path runs can render that guidance + /// exactly once without re-listing MCP tools. pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, turn_context: &TurnContext, - user_input: &[UserInput], + explicit_plugin_instructions: &ExplicitPluginInstructionsContext, ) { let reference_context_item = { let state = self.state.lock().await; state.reference_context_item() }; let context_items = if reference_context_item.is_none() { - self.build_initial_context(turn_context, Some(user_input)) + self.build_initial_context(turn_context, Some(explicit_plugin_instructions)) .await } else { // Steady-state path: append only context diffs to minimize token overhead. self.build_settings_update_items( reference_context_item.as_ref(), turn_context, - user_input, + explicit_plugin_instructions, ) .await }; @@ -5595,8 +5559,9 @@ pub(crate) async fn run_turn( .plugins_manager .plugins_for_config(&turn_context.config); // Structured plugin:// mentions are resolved from the current session's - // enabled plugins here for telemetry/tool selection; the canonical context - // builders derive any explicit plugin guidance from this turn's user input. + // enabled plugins here for telemetry/tool selection. The canonical context + // builders then render explicit plugin guidance from the already-resolved + // per-turn plugin context built below. let mentioned_plugins = collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { @@ -5686,9 +5651,17 @@ pub(crate) async fn run_turn( .iter() .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) .collect::>(); + let explicit_plugin_instructions = build_explicit_plugin_instructions_context( + &mentioned_plugins, + &mcp_tools, + &available_connectors, + ); - sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref(), &input) - .await; + sess.record_context_updates_and_set_reference_context_item( + turn_context.as_ref(), + &explicit_plugin_instructions, + ) + .await; let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input); explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5b4f4a473e..7c0cd038cb 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -12,6 +12,7 @@ use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::model_info; +use crate::plugins::ExplicitPluginInstructionsContext; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; @@ -957,18 +958,24 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() assert_eq!(expected, history_before_seed.raw_items()); session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; expected.extend( session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await, ); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let history_after_second_seed = session.clone_history().await; assert_eq!( @@ -1343,7 +1350,7 @@ async fn thread_rollback_drops_last_turn_from_history() { let rollout_path = attach_rollout_recorder(&sess).await; let initial_context = sess - .build_initial_context(tc.as_ref(), /*maybe_user_input*/ None) + .build_initial_context(tc.as_ref(), /*explicit_plugin_instructions*/ None) .await; let turn_1 = vec![ user_message("turn 1 user"), @@ -1409,7 +1416,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() attach_rollout_recorder(&sess).await; let initial_context = sess - .build_initial_context(tc.as_ref(), /*maybe_user_input*/ None) + .build_initial_context(tc.as_ref(), /*explicit_plugin_instructions*/ None) .await; let turn_1 = vec![user_message("turn 1 user")]; let mut full_history = Vec::new(); @@ -1437,7 +1444,7 @@ async fn thread_rollback_fails_without_persisted_rollout_path() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess - .build_initial_context(tc.as_ref(), /*maybe_user_input*/ None) + .build_initial_context(tc.as_ref(), /*explicit_plugin_instructions*/ None) .await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -1756,7 +1763,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess - .build_initial_context(tc.as_ref(), /*maybe_user_input*/ None) + .build_initial_context(tc.as_ref(), /*explicit_plugin_instructions*/ None) .await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -1779,7 +1786,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess - .build_initial_context(tc.as_ref(), /*maybe_user_input*/ None) + .build_initial_context(tc.as_ref(), /*explicit_plugin_instructions*/ None) .await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3713,7 +3720,11 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let reference_context_item = previous_context.to_turn_context_item(); let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context, &[]) + .build_settings_update_items( + Some(&reference_context_item), + ¤t_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let environment_update = update_items @@ -3748,7 +3759,11 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { let reference_context_item = previous_context.to_turn_context_item(); let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context, &[]) + .build_settings_update_items( + Some(&reference_context_item), + ¤t_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let environment_update = update_items @@ -3783,7 +3798,7 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l .build_settings_update_items( Some(&previous_context.to_turn_context_item()), ¤t_context, - &[], + &ExplicitPluginInstructionsContext::default(), ) .await; @@ -3812,7 +3827,7 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being .build_settings_update_items( Some(&previous_context.to_turn_context_item()), ¤t_context, - &[], + &ExplicitPluginInstructionsContext::default(), ) .await; @@ -3846,7 +3861,11 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en .set_previous_turn_settings(Some(previous_turn_settings)) .await; let update_items = session - .build_settings_update_items(Some(&previous_context_item), ¤t_context, &[]) + .build_settings_update_items( + Some(&previous_context_item), + ¤t_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let developer_texts = developer_input_texts(&update_items); @@ -3864,7 +3883,7 @@ async fn build_initial_context_uses_previous_realtime_state() { turn_context.realtime_active = true; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -3880,7 +3899,7 @@ async fn build_initial_context_uses_previous_realtime_state() { state.set_reference_context_item(Some(previous_context_item)); } let resumed_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let resumed_developer_texts = developer_input_texts(&resumed_context); assert!( @@ -3907,7 +3926,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist .await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -3923,7 +3942,7 @@ async fn build_initial_context_omits_default_image_save_location_without_image_h let (session, turn_context) = make_session_and_context().await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let developer_texts = developer_input_texts(&initial_context); @@ -4038,7 +4057,7 @@ async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { .set_previous_turn_settings(Some(previous_turn_settings)) .await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -4062,7 +4081,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is .set_previous_turn_settings(Some(previous_turn_settings)) .await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -4078,11 +4097,14 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont { let (session, turn_context) = make_session_and_context().await; session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let history = session.clone_history().await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; assert_eq!(history.raw_items().to_vec(), initial_context); @@ -4111,7 +4133,10 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) .await; session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; { let mut state = session.state.lock().await; @@ -4122,14 +4147,17 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; expected_history.extend( session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await, ); assert_eq!(history.raw_items().to_vec(), expected_history); @@ -4175,12 +4203,19 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline } let update_items = session - .build_settings_update_items(Some(&previous_context_item), &turn_context, &[]) + .build_settings_update_items( + Some(&previous_context_item), + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; assert_eq!(update_items, Vec::new()); session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; assert_eq!( @@ -4226,7 +4261,7 @@ async fn build_initial_context_prepends_model_switch_message() { .set_previous_turn_settings(Some(previous_turn_settings)) .await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let ResponseItem::Message { role, content, .. } = &initial_context[0] else { @@ -4295,7 +4330,10 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei })) .await; session - .record_context_updates_and_set_reference_context_item(&turn_context, &[]) + .record_context_updates_and_set_reference_context_item( + &turn_context, + &ExplicitPluginInstructionsContext::default(), + ) .await; session.ensure_rollout_materialized().await; session.flush_rollout().await; @@ -4908,7 +4946,10 @@ async fn sample_rollout( // personality_spec) matches reconstruction. let reconstruction_turn = session.new_default_turn().await; let mut initial_context = session - .build_initial_context(reconstruction_turn.as_ref(), /*maybe_user_input*/ None) + .build_initial_context( + reconstruction_turn.as_ref(), + /*explicit_plugin_instructions*/ None, + ) .await; // Ensure personality_spec is present when Personality is enabled, so expected matches // what reconstruction produces (build_initial_context may omit it when baked into model). diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f03455e341..985a691994 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -201,7 +201,10 @@ async fn run_compact_task_inner( InitialContextInjection::BeforeLastUserMessage ) { let initial_context = sess - .build_initial_context(turn_context.as_ref(), /*maybe_user_input*/ None) + .build_initial_context( + turn_context.as_ref(), + /*explicit_plugin_instructions*/ None, + ) .await; new_history = insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 255f163874..fc60113f70 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -178,7 +178,7 @@ pub(crate) async fn process_compacted_history( initial_context_injection, InitialContextInjection::BeforeLastUserMessage ) { - sess.build_initial_context(turn_context, /*maybe_user_input*/ None) + sess.build_initial_context(turn_context, /*explicit_plugin_instructions*/ None) .await } else { Vec::new() diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 628e52989a..9519c33293 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -10,7 +10,7 @@ async fn process_compacted_history_with_test_session( .set_previous_turn_settings(previous_turn_settings.cloned()) .await; let initial_context = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; let refreshed = crate::compact_remote::process_compacted_history( &session, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3c508e05ff..2aef5806cd 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -954,6 +954,44 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { ); } +#[test] +fn drop_last_n_user_turns_trims_explicit_plugin_guidance_above_rolled_back_turn() { + let items = vec![ + assistant_msg("session prefix item"), + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + developer_msg( + "\nCapabilities from the `sample` plugin:\n- Apps from this plugin available in this session: `Google Calendar`.\nUse these plugin-associated capabilities to help solve the task.\n", + ), + user_input_text_msg( + "PRETURN_CONTEXT_DIFF_CWD", + ), + user_input_text_msg("turn 2 user"), + assistant_msg("turn 2 assistant"), + ]; + + let modalities = default_input_modalities(); + let mut history = create_history_with_items(items); + let reference_context_item = reference_context_item(); + history.set_reference_context_item(Some(reference_context_item.clone())); + history.drop_last_n_user_turns(1); + + assert_eq!( + history.clone().for_prompt(&modalities), + vec![ + assistant_msg("session prefix item"), + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + ] + ); + assert_eq!( + serde_json::to_value(history.reference_context_item()) + .expect("serialize retained reference context item"), + serde_json::to_value(Some(reference_context_item)) + .expect("serialize expected reference context item") + ); +} + #[test] fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() { let items = vec![ diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index e39d37b9a2..a316da965c 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,6 +1,8 @@ use crate::codex::PreviousTurnSettings; use crate::codex::TurnContext; use crate::environment_context::EnvironmentContext; +use crate::plugins::ExplicitPluginInstructionsContext; +use crate::plugins::build_plugin_developer_sections; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; @@ -192,7 +194,7 @@ pub(crate) fn build_settings_update_items( shell: &Shell, exec_policy: &Policy, personality_feature_enabled: bool, - additional_developer_sections: Vec, + explicit_plugin_instructions: &ExplicitPluginInstructionsContext, ) -> Vec { // TODO(ccunningham): build_settings_update_items still does not cover every // model-visible item emitted by build_initial_context. Persist the remaining @@ -211,7 +213,9 @@ pub(crate) fn build_settings_update_items( .into_iter() .flatten() .map(DeveloperInstructions::into_text) - .chain(additional_developer_sections) + .chain(build_plugin_developer_sections( + explicit_plugin_instructions, + )) .collect::>(); let mut items = Vec::with_capacity(2); diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 5e174944f1..e181df1452 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -15,6 +15,7 @@ use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::user_input::UserInput; use tracing::warn; @@ -28,6 +29,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", "", COLLABORATION_MODE_OPEN_TAG, + EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, "", ]; diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index 02e53515e4..155a47a402 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -7,20 +7,34 @@ use crate::mcp_connection_manager::ToolInfo; use crate::plugins::PluginCapabilitySummary; use crate::plugins::render_explicit_plugin_instructions; -pub(crate) fn build_plugin_developer_sections( +/// Turn-local data needed to render explicit plugin-mention guidance inside the +/// canonical pre-user developer envelope. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct ExplicitPluginInstructionsContext { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExplicitPluginInstructionsEntry { + plugin: PluginCapabilitySummary, + available_mcp_servers: Vec, + available_apps: Vec, +} + +/// Capture the turn-local plugin/tool/app ingredients needed to render explicit plugin guidance +/// later in the canonical context builders, without re-listing MCP tools. +pub(crate) fn build_explicit_plugin_instructions_context( mentioned_plugins: &[PluginCapabilitySummary], mcp_tools: &HashMap, available_connectors: &[connectors::AppInfo], -) -> Vec { +) -> ExplicitPluginInstructionsContext { if mentioned_plugins.is_empty() { - return Vec::new(); + return ExplicitPluginInstructionsContext::default(); } - // Turn each explicit plugin mention into developer-message sections that - // can be folded into the canonical pre-user developer envelope for this turn. - mentioned_plugins + let entries = mentioned_plugins .iter() - .filter_map(|plugin| { + .map(|plugin| { let available_mcp_servers = mcp_tools .values() .filter(|tool| { @@ -47,7 +61,36 @@ pub(crate) fn build_plugin_developer_sections( .collect::>() .into_iter() .collect::>(); - render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps) + + ExplicitPluginInstructionsEntry { + plugin: plugin.clone(), + available_mcp_servers, + available_apps, + } + }) + .collect(); + + ExplicitPluginInstructionsContext { entries } +} + +/// Render explicit plugin-mention guidance from the already-resolved per-turn plugin context. +/// +/// The live turn path builds `ExplicitPluginInstructionsContext` once from the current turn's +/// plugin/tool/app inventory, then whichever canonical context builder runs uses this renderer. +pub(crate) fn build_plugin_developer_sections( + explicit_plugin_instructions: &ExplicitPluginInstructionsContext, +) -> Vec { + // Turn each explicit plugin mention into developer-message sections that + // can be folded into the canonical pre-user developer envelope for this turn. + explicit_plugin_instructions + .entries + .iter() + .filter_map(|entry| { + render_explicit_plugin_instructions( + &entry.plugin, + &entry.available_mcp_servers, + &entry.available_apps, + ) }) .collect() } diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 41351612b9..13c34bca39 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod test_support; mod toggles; pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; +pub(crate) use injection::ExplicitPluginInstructionsContext; +pub(crate) use injection::build_explicit_plugin_instructions_context; pub(crate) use injection::build_plugin_developer_sections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplace; diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index aa1de1a4c2..6715e4b1cb 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -1,4 +1,6 @@ use crate::plugins::PluginCapabilitySummary; +use codex_protocol::protocol::EXPLICIT_PLUGIN_INSTRUCTIONS_CLOSE_TAG; +use codex_protocol::protocol::EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; @@ -84,7 +86,10 @@ pub(crate) fn render_explicit_plugin_instructions( lines.push("Use these plugin-associated capabilities to help solve the task.".to_string()); - Some(lines.join("\n")) + let body = lines.join("\n"); + Some(format!( + "{EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG}\n{body}\n{EXPLICIT_PLUGIN_INSTRUCTIONS_CLOSE_TAG}" + )) } #[cfg(test)] diff --git a/codex-rs/core/src/plugins/render_tests.rs b/codex-rs/core/src/plugins/render_tests.rs index a0ec531209..dd785a0a69 100644 --- a/codex-rs/core/src/plugins/render_tests.rs +++ b/codex-rs/core/src/plugins/render_tests.rs @@ -21,3 +21,22 @@ fn render_plugins_section_includes_descriptions_and_skill_naming_guidance() { assert_eq!(rendered, expected); } + +#[test] +fn render_explicit_plugin_instructions_wraps_turn_local_guidance_in_stable_tag() { + let rendered = render_explicit_plugin_instructions( + &PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + has_skills: true, + ..PluginCapabilitySummary::default() + }, + &[], + &["Google Calendar".to_string()], + ) + .expect("explicit plugin instructions should render"); + + let expected = "\nCapabilities from the `sample` plugin:\n- Skills from this plugin are prefixed with `sample:`.\n- Apps from this plugin available in this session: `Google Calendar`.\nUse these plugin-associated capabilities to help solve the task.\n"; + + assert_eq!(rendered, expected); +} diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d124119b98..b993f212ec 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -196,7 +196,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; let mut items = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 9e3f09d7e3..69b8450d61 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -125,7 +125,7 @@ fn truncates_rollout_from_start_applies_thread_rollback_markers() { async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; let mut items = session - .build_initial_context(&turn_context, /*maybe_user_input*/ None) + .build_initial_context(&turn_context, /*explicit_plugin_instructions*/ None) .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index cb899969d9..8feb8453a2 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -4,6 +4,7 @@ use std::sync::OnceLock; use crate::responses::ResponsesRequest; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::protocol::EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -281,6 +282,9 @@ fn canonicalize_snapshot_text(text: &str) -> String { if text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG) { return "".to_string(); } + if text.starts_with(EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG) { + return "".to_string(); + } if text.starts_with("# AGENTS.md instructions for ") { return "".to_string(); } diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 5a912d6aeb..131479ac5f 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -8,6 +8,7 @@ use std::time::Instant; use anyhow::Result; use codex_core::CodexAuth; use codex_features::Feature; +use codex_protocol::protocol::EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; @@ -325,14 +326,14 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { assert!( developer_messages .iter() - .any(|text| text.contains("Capabilities from the `sample` plugin:")), + .any(|text| text.contains(EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG)), "expected plugin mention guidance in developer messages: {developer_messages:?}" ); let user_messages = request.message_input_texts("user"); assert!( user_messages .iter() - .all(|text| !text.contains("Capabilities from the `sample` plugin:")), + .all(|text| !text.contains(EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG)), "expected plugin mention guidance to stay out of user messages: {user_messages:?}" ); assert!( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__plugins__explicit_plugin_mentions_inject_plugin_guidance.snap b/codex-rs/core/tests/suite/snapshots/all__suite__plugins__explicit_plugin_mentions_inject_plugin_guidance.snap index ffe2b4e850..3407959772 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__plugins__explicit_plugin_mentions_inject_plugin_guidance.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__plugins__explicit_plugin_mentions_inject_plugin_guidance.snap @@ -7,6 +7,6 @@ Scenario: Explicit plugin mention request layout ## Plugin Mention Request 00:message/developer[2]: [01] - [02] Capabilities from the `sample` plugin:\n- Skills from this plugin are prefixed with `sample:`.\n- Apps from this plugin available in this session: `Google Calendar`.\nUse these plugin-associated capabilities to help solv... + [02] 01:message/user:> 02:message/user:help me inspect the sample plugin diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6856f19417..377a798da5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -95,6 +95,8 @@ pub const SKILLS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const SKILLS_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = ""; +pub const EXPLICIT_PLUGIN_INSTRUCTIONS_OPEN_TAG: &str = ""; +pub const EXPLICIT_PLUGIN_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "";