Decouple recommended plugins from tool suggestions (#35839)

## What changed

- Add the stable, disabled-by-default `recommended_plugins` feature flag.
- Load recommended plugin candidates when apps and plugins are enabled and
  either `tool_suggest` or `recommended_plugins` is active.
- Keep the `request_plugin_install` tool gated by `tool_suggest`.

## Testing

- Cover the first turn after external login with `tool_suggest` both enabled
  and disabled, including the expected install-tool availability.

GitOrigin-RevId: 06d9a1c2e8dd2498a47796d833eb6d25ba528351
This commit is contained in:
Alex Daley
2026-07-28 22:47:15 +00:00
committed by copyberry
parent 12b961d4c5
commit 3a797496f1
4 changed files with 65 additions and 19 deletions

View File

@@ -27,8 +27,31 @@ use wiremock::matchers::query_param;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20);
const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010";
enum ToolSuggestFeature {
Enabled,
Disabled,
}
#[tokio::test]
async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> {
recommended_plugins_after_external_login(ToolSuggestFeature::Enabled).await
}
#[tokio::test]
async fn first_turn_after_external_login_waits_for_recommended_plugins_without_tool_suggest()
-> Result<()> {
recommended_plugins_after_external_login(ToolSuggestFeature::Disabled).await
}
async fn recommended_plugins_after_external_login(
tool_suggest_feature: ToolSuggestFeature,
) -> Result<()> {
let tool_suggest_enabled = matches!(tool_suggest_feature, ToolSuggestFeature::Enabled);
let recommended_plugins_config = if tool_suggest_enabled {
""
} else {
"recommended_plugins = true\n"
};
let server = responses::start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
Mock::given(method("GET"))
@@ -68,7 +91,9 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(
config_path,
format!("{config}\n[features]\napps = true\nplugins = true\ntool_suggest = true\n"),
format!(
"{config}\n[features]\napps = true\nplugins = true\ntool_suggest = {tool_suggest_enabled}\n{recommended_plugins_config}"
),
)?;
let sqlite_home = codex_home.path().to_string_lossy();
@@ -158,7 +183,10 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu
.flatten()
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
assert!(tool_names.contains(&"request_plugin_install"));
assert_eq!(
tool_names.contains(&"request_plugin_install"),
tool_suggest_enabled
);
assert!(!tool_names.contains(&"list_available_plugins_to_install"));
Ok(())
}

View File

@@ -633,6 +633,9 @@
"realtime_conversation": {
"type": "boolean"
},
"recommended_plugins": {
"type": "boolean"
},
"remote_compaction_v2": {
"type": "boolean"
},
@@ -5190,6 +5193,9 @@
"realtime_conversation": {
"type": "boolean"
},
"recommended_plugins": {
"type": "boolean"
},
"remote_compaction_v2": {
"type": "boolean"
},

View File

@@ -3422,23 +3422,27 @@ impl Session {
.plugins_manager
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
let recommended_plugin_candidates =
if crate::tools::spec_plan::tool_suggest_enabled(turn_context) {
let auth = self.services.auth_manager.auth().await;
let plugins_config = turn_context.config.plugins_config_input();
self.services
.plugins_manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &plugins_config,
loaded_plugins: &loaded_plugins,
auth: auth.as_ref(),
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
app_server_client_name: turn_context.app_server_client_name.as_deref(),
})
.await
} else {
None
};
let features = turn_context.config.features.get();
let recommended_plugin_candidates = if features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins)
&& (features.enabled(Feature::ToolSuggest)
|| features.enabled(Feature::RecommendedPlugins))
{
let auth = self.services.auth_manager.auth().await;
let plugins_config = turn_context.config.plugins_config_input();
self.services
.plugins_manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &plugins_config,
loaded_plugins: &loaded_plugins,
auth: auth.as_ref(),
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
app_server_client_name: turn_context.app_server_client_name.as_deref(),
})
.await
} else {
None
};
if let Some(recommended_plugins) = recommended_plugin_candidates
.as_deref()
.and_then(RecommendedPluginsInstructions::from_plugins)

View File

@@ -176,6 +176,8 @@ pub enum Feature {
NonPrefixedMcpToolNames,
/// Enable discoverable tool suggestions for apps.
ToolSuggest,
/// Include recommended plugins in model-visible context.
RecommendedPlugins,
/// Enable plugins.
Plugins,
/// Discover selected-root plugin and skill manifests through one high-level exec-server RPC.
@@ -1161,6 +1163,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::RecommendedPlugins,
key: "recommended_plugins",
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::Plugins,
key: "plugins",