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
This commit is contained in:
Alex Daley
2026-10-27 00:52:31 +00:00
committed by copyberry
parent b01148de59
commit ce7adb6a31
10 changed files with 236 additions and 17 deletions
+6
View File
@@ -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"
},
+11 -5
View File
@@ -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<ResponseItem> {
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::<BTreeSet<String>>()
.into_iter()
.collect::<Vec<_>>();
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()
}
+24 -5
View File
@@ -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<String> {
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)]
@@ -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");
+6 -1
View File
@@ -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
+15 -4
View File
@@ -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(),
@@ -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("<skill>\n<name>sample:sample-search</name>");
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:?}"
@@ -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: "<PROMPT_CACHE_KEY 1>"
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]
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
- 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] <permissions instructions>
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.
</permissions instructions>
[02] <collaboration_mode># 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 `<collaboration_mode>...<...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.
</collaboration_mode>
03:message/developer:
<multi_agent_role>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.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- 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"`.</multi_agent_role> [hash=d5928243dc643bcc]
04:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
05:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><WORKSPACE_ROOT 1></root></workspace_roots><permission_profile type...e="unrestricted" /></permission_profile></filesystem> [hash=0592734c170299c1]
</environment_context>
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 <DURATION> seconds
Output:
| []
-- request 3 (turn) --
10:message/assistant:
The Notes tool is not installed yet.
11:message/developer:
<skills_instructions>
## 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` = `<PLUGINS_CACHE>/test`
### Available skills
- notes:summarize: Summarize team notes (file: r0/notes/local/skills/summarize/SKILL.md)
</skills_instructions>
12:message/user:
I installed Notes. Use $notes:summarize and the Notes tool to check that Mira owns the kickoff.
13:message/user:
<skill>
<name>notes:summarize</name>
<path><PLUGINS_CACHE>/test/notes/local/skills/summarize/SKILL.md</path>
---
name: summarize
description: Summarize team notes
---
State the owner and date from the notes.
</skill>
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 <DURATION> 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 <DURATION> 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 <DURATION> seconds
Output:
| ECHOING: The kickoff is Friday
+8
View File
@@ -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",