add @plugin mentions (#13510)

## Note-- added plugin mentions via @, but that conflicts with file
mentions

depends and builds upon #13433.

- introduces explicit `@plugin` mentions. this injects the plugin's mcp
servers, app names, and skill name format into turn context as a dev
message.
- we do not yet have UI for these mentions, so we currently parse raw
text (as opposed to skills and apps which have UI chips, autocomplete,
etc.) this depends on a `plugins/list` app-server endpoint we can feed
the UI with, which is upcoming
- also annotate mcp and app tool descriptions with the plugin(s) they
come from. this gives the model a first class way of understanding what
tools come from which plugins, which will help implicit invocation.

### Tests
Added and updated tests, unit and integration. Also confirmed locally a
raw `@plugin` injects the dev message, and the model knows about its
apps, mcps, and skills.
This commit is contained in:
sayan-oai
2026-03-05 16:03:39 -08:00
committed by GitHub
Unverified
parent 1ed542bf31
commit 4e77ea0ec7
24 changed files with 1067 additions and 181 deletions
+73 -26
View File
@@ -203,9 +203,11 @@ use crate::memories;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
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::PluginsManager;
use crate::plugins::build_plugin_injections;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
@@ -512,7 +514,7 @@ impl Codex {
session_source_clone,
skills_manager,
plugins_manager,
mcp_manager,
mcp_manager.clone(),
file_watcher,
agent_control,
)
@@ -1530,8 +1532,8 @@ impl Session {
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
file_watcher,
agent_control,
network_proxy,
@@ -1615,6 +1617,7 @@ impl Session {
.map(|(name, _)| name.clone())
.collect();
required_mcp_servers.sort();
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref());
{
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
@@ -1629,6 +1632,7 @@ impl Session {
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
)
.await;
{
@@ -3574,6 +3578,10 @@ impl Session {
) {
let auth = self.services.auth_manager.auth().await;
let config = self.get_config().await;
let tool_plugin_provenance = self
.services
.mcp_manager
.tool_plugin_provenance(config.as_ref());
let mcp_servers = with_codex_apps_mcp(
mcp_servers,
self.features.enabled(Feature::Apps),
@@ -3601,6 +3609,7 @@ impl Session {
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,
)
.await;
{
@@ -4942,25 +4951,38 @@ pub(crate) async fn run_turn(
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
let mcp_tools = match sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.or_cancel(&cancellation_token)
.await
{
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
// Plain-text @plugin mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
let mentioned_plugins =
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
let mcp_tools =
if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() {
// Plugin mentions need raw MCP/app inventory even when app tools
// are normally hidden so we can describe the plugin's currently
// usable capabilities for this turn.
match sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.or_cancel(&cancellation_token)
.await
{
Ok(mcp_tools) => mcp_tools,
Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None,
Err(_) => HashMap::new(),
}
} else {
HashMap::new()
};
let plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
let connectors = connectors::merge_plugin_apps_with_accessible(
plugin_apps.effective_apps(),
loaded_plugins.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
);
connectors::with_app_enabled_state(connectors, &turn_context.config)
@@ -5021,12 +5043,29 @@ pub(crate) async fn run_turn(
.await;
}
let plugin_items =
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
&skill_items,
&available_connectors,
&skill_name_counts_lower,
));
// Explicit @plugin mentions can make a plugin's enabled apps callable for
// this turn without persisting those connectors as sticky user selections.
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
turn_enabled_connectors.extend(
mentioned_plugins
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.filter(|connector_id| {
available_connectors
.iter()
.any(|connector| connector.is_enabled && connector.id == *connector_id)
}),
);
let connector_names_by_id = available_connectors
.iter()
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
@@ -5064,6 +5103,10 @@ pub(crate) async fn run_turn(
sess.record_conversation_items(&turn_context, &skill_items)
.await;
}
if !plugin_items.is_empty() {
sess.record_conversation_items(&turn_context, &plugin_items)
.await;
}
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
@@ -5132,7 +5175,7 @@ pub(crate) async fn run_turn(
&mut client_session,
turn_metadata_header.as_deref(),
sampling_request_input,
&explicitly_enabled_connectors,
&turn_enabled_connectors,
skills_outcome,
&mut server_model_warning_emitted_for_turn,
cancellation_token.child_token(),
@@ -5678,17 +5721,17 @@ async fn built_tools(
.or_cancel(cancellation_token)
.await?;
drop(mcp_connection_manager);
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
let connectors = if turn_context.features.enabled(Feature::Apps) {
let plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let connectors = connectors::merge_plugin_apps_with_accessible(
plugin_apps.effective_apps(),
loaded_plugins.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
);
Some(connectors::with_app_enabled_state(
@@ -5699,6 +5742,8 @@ async fn built_tools(
None
};
// Keep the connector-grouped app view around for the router even though
// app tools only become prompt-visible after explicit selection/discovery.
let app_tools = connectors.as_ref().map(|connectors| {
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
});
@@ -6779,6 +6824,7 @@ mod tests {
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
@@ -6866,6 +6912,7 @@ mod tests {
},
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(),
}
}