annotate tools with plugin info, inject plugin info on @mention

This commit is contained in:
Sayan Sisodiya
2026-03-04 19:51:41 -08:00
parent 3c9cb670ca
commit e0b157279f
8 changed files with 656 additions and 259 deletions
+110 -30
View File
@@ -204,7 +204,10 @@ 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::PluginCapabilitySummary;
use crate::plugins::PluginsManager;
use crate::plugins::annotate_tools_with_plugin_sources;
use crate::plugins::render_explicit_plugin_instructions;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
@@ -4926,19 +4929,32 @@ pub(crate) async fn run_turn(
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
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,
// 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_index().plugins());
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 available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
let connectors = connectors::merge_plugin_apps_with_accessible(
loaded_plugins.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
@@ -4953,18 +4969,6 @@ pub(crate) async fn run_turn(
.map_or_else(HashMap::new, |outcome| {
build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1
});
let mentioned_plugins =
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_index().plugins());
if !mentioned_plugins.is_empty() {
trace!(
turn_id = %turn_context.sub_id,
plugins = ?mentioned_plugins
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
"resolved explicit plugin mentions"
);
}
let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| {
collect_explicit_skill_mentions(
&input,
@@ -5013,12 +5017,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()))
@@ -5056,6 +5077,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;
@@ -5124,7 +5149,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(),
@@ -5670,17 +5695,21 @@ 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);
// Annotate tool descriptions at runtime so prompt-visible tools and
// search_tool_bm25 share the same plugin provenance without mutating the
// cached MCP tool inventory.
mcp_tools = annotate_tools_with_plugin_sources(mcp_tools, loaded_plugins.capability_index());
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(
@@ -5691,6 +5720,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)
});
@@ -5735,6 +5766,55 @@ async fn built_tools(
)))
}
fn build_plugin_injections(
mentioned_plugins: &[PluginCapabilitySummary],
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
available_connectors: &[connectors::AppInfo],
) -> Vec<ResponseItem> {
if mentioned_plugins.is_empty() {
return Vec::new();
}
let visible_mcp_server_names = mcp_tools
.values()
.filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
.map(|tool| tool.server_name.clone())
.collect::<HashSet<String>>();
let enabled_connectors_by_id = available_connectors
.iter()
.filter(|connector| connector.is_enabled)
.map(|connector| {
(
connector.id.as_str(),
connectors::connector_display_label(connector),
)
})
.collect::<HashMap<&str, String>>();
// 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.
mentioned_plugins
.iter()
.filter_map(|plugin| {
let available_mcp_servers = plugin
.mcp_server_names
.iter()
.filter(|server_name| visible_mcp_server_names.contains(server_name.as_str()))
.cloned()
.collect::<Vec<_>>();
let available_apps = plugin
.app_connector_ids
.iter()
.filter_map(|connector_id| enabled_connectors_by_id.get(connector_id.0.as_str()))
.cloned()
.collect::<Vec<_>>();
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
.map(DeveloperInstructions::new)
.map(ResponseItem::from)
})
.collect()
}
#[derive(Debug)]
struct SamplingRequestResult {
needs_follow_up: bool,
+76 -43
View File
@@ -53,7 +53,7 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
///
/// This is currently the core-side fallback path for plugin mentions. It
/// matches unambiguous plugin `display_name`s from the filtered capability
/// index, case-insensitively, using a conservative `[A-Za-z0-9_:-]` token set.
/// index, case-insensitively, by scanning for exact `@display name` matches.
///
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
@@ -71,43 +71,59 @@ pub(crate) fn collect_explicit_plugin_mentions(
return Vec::new();
}
let mut display_name_counts = HashMap::new();
for plugin in plugins {
*display_name_counts
.entry(plugin.display_name.to_lowercase())
.or_insert(0) += 1;
}
let mut display_names = display_name_counts.keys().cloned().collect::<Vec<_>>();
display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len()));
let mut mentioned_display_names = HashSet::new();
for text in input.iter().filter_map(|item| match item {
UserInput::Text { text, .. } => Some(text.as_str()),
_ => None,
}) {
let text_bytes = text.as_bytes();
let text = text.to_lowercase();
let mut index = 0;
while index < text_bytes.len() {
if text_bytes[index] != b'@' {
index += 1;
while let Some(relative_at_sign) = text[index..].find('@') {
let at_sign = index + relative_at_sign;
if text[..at_sign]
.chars()
.next_back()
.is_some_and(is_plugin_mention_body_char)
{
index = at_sign + 1;
continue;
}
if index > 0 && is_plugin_mention_char(text_bytes[index - 1]) {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first_name_byte) = text_bytes.get(name_start) else {
index += 1;
let Some((matched_display_name, matched_len)) =
display_names.iter().find_map(|display_name| {
text[at_sign + 1..].starts_with(display_name).then(|| {
let end = at_sign + 1 + display_name.len();
text[end..]
.chars()
.next()
.is_none_or(|ch| !is_plugin_mention_body_char(ch))
.then_some((display_name, display_name.len()))
})?
})
else {
index = at_sign + 1;
continue;
};
if !is_plugin_mention_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_plugin_mention_char(*next_byte)
if display_name_counts
.get(matched_display_name)
.copied()
.unwrap_or(0)
== 1
{
name_end += 1;
mentioned_display_names.insert(matched_display_name.clone());
}
mentioned_display_names.insert(text[name_start..name_end].to_ascii_lowercase());
index = name_end;
index = at_sign + 1 + matched_len;
}
}
@@ -115,23 +131,13 @@ pub(crate) fn collect_explicit_plugin_mentions(
return Vec::new();
}
let mut display_name_counts = HashMap::new();
for plugin in plugins {
*display_name_counts
.entry(plugin.display_name.to_ascii_lowercase())
.or_insert(0) += 1;
}
let mut selected = Vec::new();
let mut seen_display_names = HashSet::new();
for plugin in plugins {
let display_name = plugin.display_name.to_ascii_lowercase();
let display_name = plugin.display_name.to_lowercase();
if !mentioned_display_names.contains(&display_name) {
continue;
}
if display_name_counts.get(&display_name).copied().unwrap_or(0) != 1 {
continue;
}
if seen_display_names.insert(display_name) {
selected.push(plugin.clone());
}
@@ -169,8 +175,8 @@ pub(crate) fn build_connector_slug_counts(
counts
}
fn is_plugin_mention_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
fn is_plugin_mention_body_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
}
#[cfg(test)]
@@ -260,19 +266,46 @@ mod tests {
}
#[test]
fn collect_explicit_plugin_mentions_skips_ambiguous_display_names() {
fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() {
let spaced_plugins = vec![plugin("Google Calendar")];
let spaced_mentioned = collect_explicit_plugin_mentions(
&[text_input("use @Google Calendar")],
&spaced_plugins,
);
assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]);
let unicode_plugins = vec![plugin("Café")];
let unicode_mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins);
assert_eq!(unicode_mentioned, vec![plugin("Café")]);
}
#[test]
fn collect_explicit_plugin_mentions_prefers_longer_display_names() {
let plugins = vec![plugin("Google"), plugin("Google Calendar")];
let mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
assert_eq!(mentioned, vec![plugin("Google Calendar")]);
}
#[test]
fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() {
let plugins = vec![
plugin("Google"),
PluginCapabilitySummary {
config_name: "sample@test".to_string(),
..plugin("sample")
config_name: "calendar-1@test".to_string(),
..plugin("Google Calendar")
},
PluginCapabilitySummary {
config_name: "sample@prod".to_string(),
..plugin("sample")
config_name: "calendar-2@test".to_string(),
..plugin("Google Calendar")
},
];
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
let mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
}
+184 -70
View File
@@ -1,9 +1,8 @@
use super::load_plugin_manifest;
use super::marketplace::MarketplaceError;
use super::marketplace::resolve_marketplace_plugin;
use super::plugin_manifest_name;
use super::store::DEFAULT_PLUGIN_VERSION;
use super::store::PluginId;
use super::store::PluginInstallRequest;
use super::store::PluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
@@ -18,6 +17,7 @@ use crate::config_loader::ConfigLayerStack;
use crate::features::Feature;
use crate::features::FeatureOverrides;
use crate::features::Features;
use crate::mcp_connection_manager::ToolInfo;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::MergeStrategy;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -25,6 +25,7 @@ use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
@@ -39,13 +40,6 @@ const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
pub plugin_name: String,
pub marketplace_name: String,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
@@ -100,10 +94,146 @@ impl PluginCapabilitySummary {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginCapabilityIndex {
plugins: Vec<PluginCapabilitySummary>,
plugin_index_by_config_name: HashMap<String, usize>,
plugin_indexes_by_connector_id: HashMap<AppConnectorId, Vec<usize>>,
plugin_indexes_by_mcp_server_name: HashMap<String, Vec<usize>>,
}
impl PluginCapabilityIndex {
fn from_plugins(plugins: &[LoadedPlugin]) -> Self {
let mut capability_index = Self::default();
for plugin in plugins {
let Some(summary) = PluginCapabilitySummary::from_plugin(plugin) else {
continue;
};
let plugin_index = capability_index.plugins.len();
capability_index
.plugin_index_by_config_name
.insert(summary.config_name.clone(), plugin_index);
for connector_id in &summary.app_connector_ids {
capability_index
.plugin_indexes_by_connector_id
.entry(connector_id.clone())
.or_default()
.push(plugin_index);
}
for server_name in &summary.mcp_server_names {
capability_index
.plugin_indexes_by_mcp_server_name
.entry(server_name.clone())
.or_default()
.push(plugin_index);
}
capability_index.plugins.push(summary);
}
capability_index
}
pub fn plugins(&self) -> &[PluginCapabilitySummary] {
&self.plugins
}
pub fn plugin_for_config_name(&self, config_name: &str) -> Option<&PluginCapabilitySummary> {
self.plugin_index_by_config_name
.get(config_name)
.map(|plugin_index| &self.plugins[*plugin_index])
}
pub fn plugins_for_connector_id(
&self,
connector_id: &AppConnectorId,
) -> Vec<&PluginCapabilitySummary> {
self.plugin_indexes_by_connector_id
.get(connector_id)
.into_iter()
.flatten()
.map(|plugin_index| &self.plugins[*plugin_index])
.collect()
}
pub fn plugins_for_mcp_server_name(&self, server_name: &str) -> Vec<&PluginCapabilitySummary> {
self.plugin_indexes_by_mcp_server_name
.get(server_name)
.into_iter()
.flatten()
.map(|plugin_index| &self.plugins[*plugin_index])
.collect()
}
}
pub(crate) fn annotate_tools_with_plugin_sources(
mut tools: HashMap<String, ToolInfo>,
capability_index: &PluginCapabilityIndex,
) -> HashMap<String, ToolInfo> {
for tool in tools.values_mut() {
// Recover plugin provenance from the app connector or MCP server and
// append it to the shared description text used by both prompt tools
// and search_tool_bm25 indexing.
let mut plugin_names = match tool.connector_id.as_ref() {
Some(connector_id) => capability_index
.plugins_for_connector_id(&AppConnectorId(connector_id.clone()))
.into_iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
None => capability_index
.plugins_for_mcp_server_name(tool.server_name.as_str())
.into_iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
};
if plugin_names.is_empty() {
continue;
}
plugin_names.sort_unstable();
plugin_names.dedup();
let plugin_source_note = if plugin_names.len() == 1 {
format!("This tool is part of plugin `{}`.", plugin_names[0])
} else {
format!(
"This tool is part of plugins {}.",
plugin_names
.into_iter()
.map(|plugin_name| format!("`{plugin_name}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
let description = tool
.tool
.description
.as_deref()
.map(str::trim)
.unwrap_or("");
let annotated_description = if description.is_empty() {
plugin_source_note
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
format!("{description} {plugin_source_note}")
} else {
format!("{description}. {plugin_source_note}")
};
tool.tool.description = Some(Cow::Owned(annotated_description));
}
tools
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginLoadOutcome {
plugins: Vec<LoadedPlugin>,
capability_summaries: Vec<PluginCapabilitySummary>,
capability_index: PluginCapabilityIndex,
}
impl Default for PluginLoadOutcome {
@@ -114,13 +244,10 @@ impl Default for PluginLoadOutcome {
impl PluginLoadOutcome {
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
let capability_summaries = plugins
.iter()
.filter_map(PluginCapabilitySummary::from_plugin)
.collect::<Vec<_>>();
let capability_index = PluginCapabilityIndex::from_plugins(&plugins);
Self {
plugins,
capability_summaries,
capability_index,
}
}
@@ -163,8 +290,8 @@ impl PluginLoadOutcome {
apps
}
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
&self.capability_summaries
pub fn capability_index(&self) -> &PluginCapabilityIndex {
&self.capability_index
}
pub fn plugins(&self) -> &[LoadedPlugin] {
@@ -239,17 +366,10 @@ impl PluginsManager {
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
let resolved = resolve_marketplace_plugin(
&request.cwd,
&request.plugin_name,
&request.marketplace_name,
)?;
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || {
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
})
.await
.map_err(PluginInstallError::join)??;
let result = tokio::task::spawn_blocking(move || store.install(request))
.await
.map_err(PluginInstallError::join)??;
ConfigService::new_with_defaults(self.codex_home.clone())
.write_value(ConfigValueWriteParams {
@@ -271,9 +391,6 @@ impl PluginsManager {
#[derive(Debug, thiserror::Error)]
pub enum PluginInstallError {
#[error("{0}")]
Marketplace(#[from] MarketplaceError),
#[error("{0}")]
Store(#[from] PluginStoreError),
@@ -288,18 +405,6 @@ impl PluginInstallError {
fn join(source: tokio::task::JoinError) -> Self {
Self::Join(source)
}
pub fn is_invalid_request(&self) -> bool {
matches!(
self,
Self::Marketplace(
MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::DuplicatePlugin { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
}
}
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
@@ -888,6 +993,15 @@ mod tests {
AppConnectorId("connector_gmail".to_string()),
]
);
assert_eq!(
outcome
.capability_index()
.plugins_for_connector_id(&AppConnectorId("connector_example".to_string()))
.into_iter()
.map(|plugin| plugin.config_name.clone())
.collect::<Vec<_>>(),
vec!["plugin-a@test".to_string(), "plugin-b@test".to_string()]
);
}
#[test]
@@ -956,7 +1070,7 @@ mod tests {
]);
assert_eq!(
outcome.capability_summaries(),
outcome.capability_index().plugins(),
&[
PluginCapabilitySummary {
has_skills: true,
@@ -977,6 +1091,30 @@ mod tests {
},
]
);
assert_eq!(
outcome
.capability_index()
.plugins_for_connector_id(&connector("connector_example"))
.into_iter()
.map(|plugin| plugin.config_name.clone())
.collect::<Vec<_>>(),
vec!["alpha@test".to_string(), "beta@test".to_string()]
);
assert_eq!(
outcome
.capability_index()
.plugins_for_mcp_server_name("alpha")
.into_iter()
.map(|plugin| plugin.config_name.clone())
.collect::<Vec<_>>(),
vec!["alpha@test".to_string()]
);
assert_eq!(
outcome
.capability_index()
.plugin_for_config_name("empty@test"),
None
);
}
#[test]
@@ -1063,36 +1201,12 @@ mod tests {
#[tokio::test]
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(
&repo_root.join(".agents/plugins"),
"sample-plugin",
"sample-plugin",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
)
.unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_name: "debug".to_string(),
cwd: repo_root.clone(),
source_path: tmp.path().join("sample-plugin"),
marketplace_name: None,
})
.await
.unwrap();
+2
View File
@@ -11,9 +11,11 @@ pub use manager::PluginInstallError;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub(crate) use manager::annotate_tools_with_plugin_sources;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;
pub use store::PluginInstallResult;
+48
View File
@@ -30,6 +30,54 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
Some(lines.join("\n"))
}
pub(crate) fn render_explicit_plugin_instructions(
plugin: &PluginCapabilitySummary,
available_mcp_servers: &[String],
available_apps: &[String],
) -> Option<String> {
let mut lines = vec![format!(
"Capabilities from the `{}` plugin:",
plugin.display_name
)];
if plugin.has_skills {
lines.push(format!(
"- Skills from this plugin are prefixed with `{}:`.",
plugin.display_name
));
}
if !available_mcp_servers.is_empty() {
lines.push(format!(
"- MCP servers from this plugin available in this session: {}.",
available_mcp_servers
.iter()
.map(|server| format!("`{server}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !available_apps.is_empty() {
lines.push(format!(
"- Apps from this plugin available in this session: {}.",
available_apps
.iter()
.map(|app| format!("`{app}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if lines.len() == 1 {
return None;
}
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
Some(lines.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -13,6 +13,7 @@ use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use crate::plugins::annotate_tools_with_plugin_sources;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -125,6 +126,14 @@ impl ToolHandler for SearchToolBm25Handler {
);
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
let loaded_plugins = session
.services
.plugins_manager
.plugins_for_config(&turn.config);
// Reuse the same runtime annotation as prompt tool building so plugin
// names become searchable through normal description indexing.
let mcp_tools =
annotate_tools_with_plugin_sources(mcp_tools, loaded_plugins.capability_index());
let mut entries: Vec<ToolEntry> = mcp_tools
.into_iter()
+123 -116
View File
@@ -14,7 +14,6 @@ use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -22,41 +21,48 @@ use core_test_support::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use dunce::canonicalize as normalize_path;
use tempfile::TempDir;
use wiremock::MockServer;
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
let plugin_root = home.path().join("plugins/cache/test/sample/local");
let skill_dir = plugin_root.join("skills/sample-search");
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
home.path().join("plugins/cache/test/sample/local")
}
fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf {
let plugin_root = sample_plugin_root(home);
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
)
.expect("write plugin manifest");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
),
)
.expect("write config");
plugin_root
}
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let skill_dir = plugin_root.join("skills/sample-search");
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: inspect sample data\n---\n\n# body\n",
)
.expect("write plugin skill");
std::fs::write(
home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
skill_dir.join("SKILL.md")
}
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
let plugin_root = home.path().join("plugins/cache/test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
let plugin_root = write_sample_plugin_manifest_and_config(home);
std::fs::write(
plugin_root.join(".mcp.json"),
format!(
@@ -70,21 +76,10 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
),
)
.expect("write plugin mcp config");
std::fs::write(
home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
}
fn write_plugin_app_plugin(home: &TempDir) {
let plugin_root = home.path().join("plugins/sample");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
let plugin_root = write_sample_plugin_manifest_and_config(home);
std::fs::write(
plugin_root.join(".app.json"),
r#"{
@@ -96,14 +91,6 @@ fn write_plugin_app_plugin(home: &TempDir) {
}"#,
)
.expect("write plugin app config");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
}
async fn build_plugin_test_codex(
@@ -120,6 +107,32 @@ async fn build_plugin_test_codex(
.codex)
}
async fn build_apps_enabled_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
chatgpt_base_url: String,
) -> Result<Arc<codex_core::CodexThread>> {
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config
.features
.disable(Feature::AppsMcpGateway)
.expect("test config should allow feature update");
config.chatgpt_base_url = chatgpt_base_url;
});
Ok(builder
.build(server)
.await
.expect("create new conversation")
.codex)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
@@ -137,6 +150,22 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
.unwrap_or_default()
}
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
.and_then(|tools| {
tools.iter().find_map(|tool| {
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
tool.get("description")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
} else {
None
}
})
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_skills_append_to_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
.await;
let codex_home = Arc::new(TempDir::new()?);
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_skill_plugin(codex_home.as_ref());
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
codex
@@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
"expected plugins section present"
);
assert!(
instructions_text.contains("### Available plugins\n- `sample`"),
"expected enabled plugin list in instructions"
);
assert!(
instructions_text.contains("### How to use plugins"),
"expected plugin usage guidance heading"
);
assert!(
instructions_text.contains("## Skills"),
"expected skills section present"
instructions_text.contains("`sample`"),
"expected enabled plugin name in instructions"
);
assert!(
instructions_text.contains("sample:sample-search: inspect sample data"),
"expected namespaced plugin skill summary"
);
let expected_path = normalize_path(skill_path)?;
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
assert!(
instructions_text.contains(&expected_path_str),
"expected path {expected_path_str} in instructions"
);
assert!(
instructions_text.find("## Plugins") < instructions_text.find("## Skills"),
"expected plugins section before skills section"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let mock = mount_sse_sequence(
let mock = mount_sse_once(
&server,
vec![
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let codex_home = Arc::new(TempDir::new()?);
let rmcp_test_server_bin = match stdio_server_bin() {
Ok(bin) => bin,
Err(err) => {
eprintln!("test_stdio_server binary not available, skipping test: {err}");
return Ok(());
}
};
write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
write_plugin_app_plugin(codex_home.as_ref());
#[allow(clippy::expect_used)]
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config
.features
.disable(Feature::AppsMcpGateway)
.expect("test config should allow feature update");
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
let codex =
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
.await?;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text: "Use @sample for this task.".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
@@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "Use $google-calendar and then call tools.".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let first_tools = tool_names(&requests[0].body_json());
let request = mock.single_request();
let developer_messages = request.message_input_texts("developer");
assert!(
!first_tools
developer_messages
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"app tools should stay hidden before plugin app mention: {first_tools:?}"
);
let second_tools = tool_names(&requests[1].body_json());
assert!(
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"calendar create tool should be available after plugin app mention: {second_tools:?}"
.any(|text| text.contains("Skills from this plugin")),
"expected plugin skills guidance: {developer_messages:?}"
);
assert!(
second_tools
developer_messages
.iter()
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
"calendar list tool should be available after plugin app mention: {second_tools:?}"
.any(|text| text.contains("MCP servers from this plugin")),
"expected visible plugin MCP guidance: {developer_messages:?}"
);
assert!(
developer_messages
.iter()
.any(|text| text.contains("Apps from this plugin")),
"expected visible plugin app guidance: {developer_messages:?}"
);
let request_body = request.body_json();
let request_tools = tool_names(&request_body);
assert!(
request_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
);
let echo_description = tool_description(&request_body, "mcp__sample__echo")
.expect("plugin MCP tool description should be present");
assert!(
echo_description.contains("This tool is part of plugin `sample`."),
"expected plugin MCP provenance in tool description: {echo_description:?}"
);
let calendar_description =
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"
);
Ok(())
+104
View File
@@ -361,6 +361,110 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let call_id = "tool-search";
let args = json!({
"query": "sample",
"limit": 2,
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = Arc::new(tempfile::TempDir::new()?);
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "calendar"
}
}
}"#,
)
.expect("write plugin app config");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
let mut builder =
configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find sample plugin tools",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
let result_tools = search_result_tools(&search_output_payload);
assert_eq!(result_tools.len(), 2, "expected 2 search results");
assert!(
result_tools.iter().all(|tool| {
tool.get("description")
.and_then(Value::as_str)
.is_some_and(|description| {
description.contains("This tool is part of plugin `sample`.")
})
}),
"expected plugin provenance in search result descriptions: {search_output_payload:?}"
);
assert!(
result_tools
.iter()
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }),
"expected calendar create tool in search results: {search_output_payload:?}"
);
assert!(
result_tools
.iter()
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }),
"expected calendar list tool in search results: {search_output_payload:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_selection_persists_across_turns() -> Result<()> {
skip_if_no_network!(Ok(()));