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",