enable text-based @plugin mentions

This commit is contained in:
Sayan Sisodiya
2026-03-03 23:00:44 -08:00
parent d44398905b
commit 3c9cb670ca
2 changed files with 165 additions and 5 deletions

View File

@@ -201,6 +201,7 @@ 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;
@@ -4921,6 +4922,10 @@ pub(crate) async fn run_turn(
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
let loaded_plugins = sess
.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
@@ -4934,12 +4939,8 @@ pub(crate) async fn run_turn(
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
};
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),
);
connectors::with_app_enabled_state(connectors, &turn_context.config)
@@ -4952,6 +4953,18 @@ 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,

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
use codex_protocol::user_input::UserInput;
use crate::connectors;
use crate::plugins::PluginCapabilitySummary;
use crate::skills::SkillMetadata;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
@@ -48,6 +49,97 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
.collect()
}
/// Collect explicit plain-text `@plugin` mentions from user text.
///
/// 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.
///
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
/// turn-time parsing.
///
/// Long term, explicit plugin picks should come through structured
/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list
/// plugins and the UI has plugin-mention support (likely a plugins/list app-server
/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps.
pub(crate) fn collect_explicit_plugin_mentions(
input: &[UserInput],
plugins: &[PluginCapabilitySummary],
) -> Vec<PluginCapabilitySummary> {
if plugins.is_empty() {
return Vec::new();
}
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 mut index = 0;
while index < text_bytes.len() {
if text_bytes[index] != b'@' {
index += 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;
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)
{
name_end += 1;
}
mentioned_display_names.insert(text[name_start..name_end].to_ascii_lowercase());
index = name_end;
}
}
if mentioned_display_names.is_empty() {
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();
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());
}
}
selected
}
pub(crate) fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
@@ -77,6 +169,10 @@ 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':')
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
@@ -85,6 +181,8 @@ mod tests {
use pretty_assertions::assert_eq;
use super::collect_explicit_app_ids;
use super::collect_explicit_plugin_mentions;
use crate::plugins::PluginCapabilitySummary;
fn text_input(text: &str) -> UserInput {
UserInput::Text {
@@ -93,6 +191,16 @@ mod tests {
}
}
fn plugin(display_name: &str) -> PluginCapabilitySummary {
PluginCapabilitySummary {
config_name: format!("{display_name}@test"),
display_name: display_name.to_string(),
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}
}
#[test]
fn collect_explicit_app_ids_from_linked_text_mentions() {
let input = vec![text_input("use [$calendar](app://calendar)")];
@@ -141,4 +249,43 @@ mod tests {
assert_eq!(app_ids, HashSet::<String>::new());
}
#[test]
fn collect_explicit_plugin_mentions_resolves_unique_display_names() {
let plugins = vec![plugin("sample"), plugin("other")];
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
assert_eq!(mentioned, vec![plugin("sample")]);
}
#[test]
fn collect_explicit_plugin_mentions_skips_ambiguous_display_names() {
let plugins = vec![
PluginCapabilitySummary {
config_name: "sample@test".to_string(),
..plugin("sample")
},
PluginCapabilitySummary {
config_name: "sample@prod".to_string(),
..plugin("sample")
},
];
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
}
#[test]
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
let plugins = vec![plugin("sample")];
let mentioned = collect_explicit_plugin_mentions(
&[text_input("contact sample@openai.com, do not use plugins")],
&plugins,
);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
}
}