From ce7adb6a31f2909a48afa538c2cdb7a6254a0e4b Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 27 Oct 2026 00:46:05 +0000 Subject: [PATCH] Add opt-in concise hints for explicitly selected plugins (#57317) ## What changed Add the `features.simplified_selection_hints` flag, disabled by default. When enabled, replace detailed plugin capability instructions with a concise developer message identifying the selected plugin and directing the model to use its available skills and tools. Continue omitting hints for plugins without available capabilities and bounding hint length. Preserve the existing detailed instructions when the flag is disabled. ## Testing Cover plugin refresh in an existing thread with the flag enabled and disabled. Verify that explicit skill prompts precede the simplified plugin hint and that the hint uses the developer role. GitOrigin-RevId: 64fc3347bd49fffe675f343a4733ced1b4e5e88c --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/plugins/injection.rs | 16 +- codex-rs/core/src/plugins/render.rs | 29 +++- codex-rs/core/src/plugins/render_tests.rs | 3 + codex-rs/core/src/session/turn.rs | 7 +- codex-rs/core/tests/suite/scenarios.rs | 19 ++- codex-rs/core/tests/suite/skills_extension.rs | 5 +- ...fresh_simplified_selection_hints_off.snap} | 0 ...refresh_simplified_selection_hints_on.snap | 160 ++++++++++++++++++ codex-rs/features/src/lib.rs | 8 + 10 files changed, 236 insertions(+), 17 deletions(-) rename codex-rs/core/tests/suite/snapshots/{all__suite__scenarios__astra_plugin_refresh.snap => all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_off.snap} (100%) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_on.snap diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c59d29117f..39cfb67299 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1305,6 +1305,9 @@ "shell_zsh_fork": { "type": "boolean" }, + "simplified_selection_hints": { + "type": "boolean" + }, "skill_catalog_deltas": { "type": "boolean" }, @@ -7944,6 +7947,9 @@ "shell_zsh_fork": { "type": "boolean" }, + "simplified_selection_hints": { + "type": "boolean" + }, "skill_catalog_deltas": { "type": "boolean" }, diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index 48e15247b5..f2ef1cacdc 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use codex_connectors::metadata::connector_display_label; +use codex_features::Features; use codex_protocol::models::ResponseItem; use crate::connectors; @@ -15,13 +16,13 @@ pub(crate) fn build_plugin_injections( mentioned_plugins: &[PluginCapabilitySummary], mcp_tools: &[ToolInfo], available_connectors: &[connectors::AppInfo], + features: &Features, ) -> Vec { if mentioned_plugins.is_empty() { return Vec::new(); } - // Turn each explicit plugin mention into a developer hint that points the - // model at the plugin's visible MCP servers, enabled apps, and skill prefix. + // Turn each explicit plugin mention with available capabilities into a developer hint. mentioned_plugins .iter() .filter_map(|plugin| { @@ -51,9 +52,14 @@ pub(crate) fn build_plugin_injections( .collect::>() .into_iter() .collect::>(); - render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps) - .map(PluginInstructions::new) - .map(ContextualUserFragment::into) + render_explicit_plugin_instructions( + plugin, + &available_mcp_servers, + &available_apps, + features, + ) + .map(PluginInstructions::new) + .map(ContextualUserFragment::into) }) .collect() } diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index 34bb1d061e..8b9dd204c8 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -3,6 +3,8 @@ use crate::context::AvailablePluginsInstructions; #[cfg(test)] use crate::context::ContextualUserFragment; use crate::plugins::PluginCapabilitySummary; +use codex_features::Feature; +use codex_features::Features; #[cfg(test)] use codex_protocol_test_support::ContentItemTestExt; use codex_utils_string::take_bytes_at_char_boundary; @@ -20,7 +22,21 @@ pub(crate) fn render_explicit_plugin_instructions( plugin: &PluginCapabilitySummary, available_mcp_servers: &[String], available_apps: &[String], + features: &Features, ) -> Option { + if features.enabled(Feature::SimplifiedSelectionHints) { + return (plugin.has_skills || !available_apps.is_empty() || !available_mcp_servers.is_empty()) + .then(|| { + bound_explicit_plugin_instructions( + format!( + "The user selected the `{}` plugin. Use its available skills and tools to complete this request.", + plugin.display_name + ), + "\n[Plugin selection truncated to fit the context limit.]", + ) + }); + } + let mut lines = vec![format!( "Capabilities from the `{}` plugin:", plugin.display_name @@ -75,18 +91,21 @@ pub(crate) fn render_explicit_plugin_instructions( lines.push("Use these plugin-associated capabilities to help solve the task.".to_string()); - Some(bound_explicit_plugin_instructions(lines.join("\n"))) + Some(bound_explicit_plugin_instructions( + lines.join("\n"), + TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX, + )) } -fn bound_explicit_plugin_instructions(rendered: String) -> String { +fn bound_explicit_plugin_instructions(rendered: String, truncation_suffix: &str) -> String { if rendered.len() <= MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES { return rendered; } - let max_prefix_bytes = MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES - .saturating_sub(TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX.len()); + let max_prefix_bytes = + MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES.saturating_sub(truncation_suffix.len()); let prefix = take_bytes_at_char_boundary(&rendered, max_prefix_bytes); - format!("{prefix}{TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX}") + format!("{prefix}{truncation_suffix}") } #[cfg(test)] diff --git a/codex-rs/core/src/plugins/render_tests.rs b/codex-rs/core/src/plugins/render_tests.rs index 8dca0e720c..8ddbaf7b5c 100644 --- a/codex-rs/core/src/plugins/render_tests.rs +++ b/codex-rs/core/src/plugins/render_tests.rs @@ -35,6 +35,7 @@ fn explicit_plugin_instructions_use_manifest_namespace_for_skills() { }, &[], &[], + &Features::default(), ) .expect("skill capability should render"); @@ -53,6 +54,7 @@ fn explicit_plugin_instructions_search_available_apps_before_fallback() { }, &[], &["Adobe".to_string()], + &Features::default(), ) .expect("app capability should render"); @@ -85,6 +87,7 @@ fn explicit_plugin_instructions_are_bounded() { }, &servers, &apps, + &Features::default(), ) .expect("MCP capability should render"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index ed445e0dae..7020ad25a3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1209,7 +1209,12 @@ async fn build_skills_and_plugins( &available_connectors, &skill_name_counts_lower, ); - let plugin_items = build_plugin_injections(mentioned_plugins, mcp_tools, &available_connectors); + let plugin_items = build_plugin_injections( + mentioned_plugins, + mcp_tools, + &available_connectors, + &turn_context.config.features, + ); let mut explicitly_enabled_connectors = collect_explicit_app_ids(user_input); explicitly_enabled_connectors.extend(skill_connector_ids); let connector_names_by_id = available_connectors diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index ad8ff10d09..164ba2599c 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -2394,15 +2394,21 @@ text(result.tools[0].description);"#, Ok(()) } +#[test_case::test_case(false; "simplified_selection_hints_off")] +#[test_case::test_case(true; "simplified_selection_hints_on")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn astra_refreshes_plugin_tools_and_skills_in_an_existing_thread() -> Result<()> { +async fn astra_refreshes_plugin_tools_and_skills_in_an_existing_thread( + simplified_selection_hints: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); core_test_support::skip_if_remote!(Ok(()), "plugin and MCP fixtures use host-local paths"); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); - let config = "[features]\nplugins = true\n\n[skills.bundled]\nenabled = false\n"; - fs::write(home.path().join("config.toml"), config)?; + let config = format!( + "[features]\nplugins = true\nsimplified_selection_hints = {simplified_selection_hints}\n\n[skills.bundled]\nenabled = false\n" + ); + fs::write(home.path().join("config.toml"), &config)?; let lookup = r#"text(ALL_TOOLS.filter(({ name }) => name === "mcp__notes__echo").map(({ name }) => name));"#; let mock = mount_sse_sequence( &server, @@ -2498,8 +2504,13 @@ async fn astra_refreshes_plugin_tools_and_skills_in_an_existing_thread() -> Resu test.submit_text_turn("Use the Notes tool again to check that the kickoff is Friday.") .await?; + let snapshot_name = if simplified_selection_hints { + "astra_plugin_refresh_simplified_selection_hints_on" + } else { + "astra_plugin_refresh_simplified_selection_hints_off" + }; insta::assert_snapshot!( - "astra_plugin_refresh", + snapshot_name, context_snapshot::format_request_history_snapshot( "Astra checks for Notes, refreshes its installed plugin without restarting, and uses the new skill and Code Mode MCP tool across turns.", &mock.requests(), diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index 0777b2e5a9..7ac7a579e9 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -874,7 +874,7 @@ async fn explicit_skill_prompt_precedes_plugin_instructions() -> Result<()> { )?; std::fs::write( codex_home.path().join("config.toml"), - "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", + "[features]\nplugins = true\nsimplified_selection_hints = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", )?; let skill_path = dunce::canonicalize(skill_dir.join("SKILL.md"))?; let (extensions, _) = @@ -917,7 +917,8 @@ async fn explicit_skill_prompt_precedes_plugin_instructions() -> Result<()> { .unwrap_or_else(|| panic!("missing prompt containing `{expected}`: {input:?}")) }; let skill_position = prompt_position("\nsample:sample-search"); - let plugin_position = prompt_position("Capabilities from the `sample` plugin:"); + let plugin_position = prompt_position("The user selected the `sample` plugin."); + assert_eq!(input[plugin_position]["role"], "developer"); assert!( skill_position < plugin_position, "host skill prompts should precede plugin instructions: {input:?}" diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_off.snap similarity index 100% rename from codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_off.snap diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_on.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_on.snap new file mode 100644 index 0000000000..8d422f3053 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_plugin_refresh_simplified_selection_hints_on.snap @@ -0,0 +1,160 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Astra checks for Notes, refreshes its installed plugin without restarting, and uses the new skill and Code Mode MCP tool across turns.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Astra checks for Notes, refreshes its installed plugin without restarting, and uses the new skill and Code Mode MCP tool across turns. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-6-astra" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"low"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - 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 +01:message/developer: + You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803] + + # When to ask the user for permission + + Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388] + + User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c] + + + + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + +02:message/developer[2]: + [01] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b] + Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + + [02] # Collaboration Mode: Default + + You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + + Your active mode changes only when new developer instructions with a different `...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812] + + ## request_user_input availability + + Use the `request_user_input` tool only when it is listed in the available tools for this turn. + + Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work. + + If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked. + + Never use the `request_user_input` tool for permission requests or permission-related escalations. + +03:message/developer: + You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + + At the start of your turn, you are the active agent. + You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. + All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + + You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202] + `send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers. + + - All agents use the same current working directory. + - As a result, edits made by one agent are immediately visible to all other agents. + + When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling. + + There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. + + Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`. [hash=d5928243dc643bcc] +04:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +05:message/user: + + + + + + [hash=0592734c170299c1] + +06:message/user: + Check whether the Notes plugin echo tool is available. +07:configuration_update:reasoning={"effort":"low"} +-- request 2 (turn) -- +08:custom_tool_call/exec:text(ALL_TOOLS.filter(({ name }) => name === "mcp__notes__echo").map(({ name }) => name)); +09:custom_tool_call_output:Script completed + Wall time seconds + Output: + | [] +-- request 3 (turn) -- +10:message/assistant: + The Notes tool is not installed yet. +11:message/developer: + + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `/test` + ### Available skills + - notes:summarize: Summarize team notes (file: r0/notes/local/skills/summarize/SKILL.md) + +12:message/user: + I installed Notes. Use $notes:summarize and the Notes tool to check that Mira owns the kickoff. +13:message/user: + + notes:summarize + /test/notes/local/skills/summarize/SKILL.md + --- + name: summarize + description: Summarize team notes + --- + + State the owner and date from the notes. + + +14:message/developer: + The user selected the `notes` plugin. Use its available skills and tools to complete this request. +-- request 4 (turn) -- +15:custom_tool_call/exec:text(ALL_TOOLS.filter(({ name }) => name === "mcp__notes__echo").map(({ name }) => name)); +16:custom_tool_call_output:Script completed + Wall time seconds + Output: + | ["mcp__notes__echo"] +-- request 5 (turn) -- +17:custom_tool_call/exec:const result = await tools.mcp__notes__echo({ message: "Mira owns the kickoff" }); text(result.structuredContent?.echo); +18:custom_tool_call_output:Script completed + Wall time seconds + Output: + | ECHOING: Mira owns the kickoff +-- request 6 (turn) -- +19:message/assistant: + The Notes plugin confirms Mira owns the kickoff. +20:message/user: + Use the Notes tool again to check that the kickoff is Friday. +-- request 7 (turn) -- +21:custom_tool_call/exec:const result = await tools.mcp__notes__echo({ message: "The kickoff is Friday" }); text(result.structuredContent?.echo); +22:custom_tool_call_output:Script completed + Wall time seconds + Output: + | ECHOING: The kickoff is Friday diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 29234609c8..0c8c1d6c81 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -297,6 +297,8 @@ pub enum Feature { RecommendedPlugins, /// Enable plugins. Plugins, + /// Use concise developer hints for explicitly selected plugins. + SimplifiedSelectionHints, /// Discover selected-root plugin and skill manifests through one high-level exec-server RPC. ExecutorCapabilityDiscovery, /// Acquire V2 executor inventories without publishing or activating their capabilities. @@ -1733,6 +1735,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SimplifiedSelectionHints, + key: "simplified_selection_hints", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ExecutorCapabilityDiscovery, key: "executor_capability_discovery",