mirror of
https://github.com/openai/codex.git
synced 2026-11-10 08:43:33 +00:00
## Why Selected plugin MCP servers may be skipped after the optional startup grace period. When a user explicitly mentions a plugin or MCP server, the turn should continue waiting so the requested tools are available to the model. ## What changed - Track explicitly mentioned plugin IDs and MCP servers across later samples, steered input, and same-turn restarts. - Require matching selected-plugin MCP servers to finish startup before capturing the tool catalog, while leaving unmentioned servers optional. - Match `plugin://` identities independently of display names and ignore targeting query parameters such as `app` and `browserFamily`. ## Testing Added coverage for linked and structured plugin mentions, direct and batched capability discovery, delayed environment attachment, steering, and same-turn restarts. GitOrigin-RevId: c509ef8df45df1a7edb0da6afdb2682dc53a4de8
233 lines
6.3 KiB
Rust
233 lines
6.3 KiB
Rust
use std::collections::HashSet;
|
|
|
|
pub struct ToolMentions<'a> {
|
|
names: HashSet<&'a str>,
|
|
paths: HashSet<&'a str>,
|
|
plain_names: HashSet<&'a str>,
|
|
}
|
|
|
|
impl<'a> ToolMentions<'a> {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.names.is_empty() && self.paths.is_empty()
|
|
}
|
|
|
|
pub fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
|
self.plain_names.iter().copied()
|
|
}
|
|
|
|
pub fn contains_plain_name(&self, name: &str) -> bool {
|
|
self.plain_names.contains(name)
|
|
}
|
|
|
|
pub fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
|
|
self.paths.iter().copied()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum ToolMentionKind {
|
|
App,
|
|
Mcp,
|
|
Plugin,
|
|
Skill,
|
|
Other,
|
|
}
|
|
|
|
const APP_PATH_PREFIX: &str = "app://";
|
|
const MCP_PATH_PREFIX: &str = "mcp://";
|
|
const PLUGIN_PATH_PREFIX: &str = "plugin://";
|
|
const SKILL_PATH_PREFIX: &str = "skill://";
|
|
const SKILL_FILENAME: &str = "SKILL.md";
|
|
const TOOL_MENTION_SIGIL: char = '$';
|
|
|
|
pub fn tool_kind_for_path(path: &str) -> ToolMentionKind {
|
|
if path.starts_with(APP_PATH_PREFIX) {
|
|
ToolMentionKind::App
|
|
} else if path.starts_with(MCP_PATH_PREFIX) {
|
|
ToolMentionKind::Mcp
|
|
} else if path.starts_with(PLUGIN_PATH_PREFIX) {
|
|
ToolMentionKind::Plugin
|
|
} else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) {
|
|
ToolMentionKind::Skill
|
|
} else {
|
|
ToolMentionKind::Other
|
|
}
|
|
}
|
|
|
|
fn is_skill_filename(path: &str) -> bool {
|
|
let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
|
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
|
|
}
|
|
|
|
pub fn app_id_from_path(path: &str) -> Option<&str> {
|
|
path.strip_prefix(APP_PATH_PREFIX)
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
/// Desktop app/browser mentions append `?app=...` or `?browserFamily=...`.
|
|
/// Ignore these targeting parameters when matching the plugin ID.
|
|
pub fn plugin_config_name_from_path(path: &str) -> Option<&str> {
|
|
path.strip_prefix(PLUGIN_PATH_PREFIX)
|
|
.and_then(|value| value.split('?').next())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
pub fn normalize_skill_path(path: &str) -> &str {
|
|
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
|
|
}
|
|
|
|
/// Extract `$tool-name` mentions from a single text input.
|
|
///
|
|
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
|
|
/// resource path is present, it is captured for exact path matching while also tracking
|
|
/// the name for fallback matching.
|
|
pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
|
|
extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)
|
|
}
|
|
|
|
pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
|
|
let text_bytes = text.as_bytes();
|
|
let mut mentioned_names: HashSet<&str> = HashSet::new();
|
|
let mut mentioned_paths: HashSet<&str> = HashSet::new();
|
|
let mut plain_names: HashSet<&str> = HashSet::new();
|
|
|
|
let mut index = 0;
|
|
while index < text_bytes.len() {
|
|
let byte = text_bytes[index];
|
|
if byte == b'['
|
|
&& let Some((name, path, end_index)) =
|
|
parse_linked_tool_mention(text, text_bytes, index, sigil)
|
|
{
|
|
if !is_common_env_var(name) {
|
|
if !matches!(
|
|
tool_kind_for_path(path),
|
|
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
|
|
) {
|
|
mentioned_names.insert(name);
|
|
}
|
|
mentioned_paths.insert(path);
|
|
}
|
|
index = end_index;
|
|
continue;
|
|
}
|
|
|
|
if byte != sigil as u8 {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
let name_start = index + 1;
|
|
let Some(first_name_byte) = text_bytes.get(name_start) else {
|
|
index += 1;
|
|
continue;
|
|
};
|
|
if !is_mention_name_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_mention_name_char(*next_byte)
|
|
{
|
|
name_end += 1;
|
|
}
|
|
|
|
let name = &text[name_start..name_end];
|
|
if !is_common_env_var(name) {
|
|
mentioned_names.insert(name);
|
|
plain_names.insert(name);
|
|
}
|
|
index = name_end;
|
|
}
|
|
|
|
ToolMentions {
|
|
names: mentioned_names,
|
|
paths: mentioned_paths,
|
|
plain_names,
|
|
}
|
|
}
|
|
|
|
fn parse_linked_tool_mention<'a>(
|
|
text: &'a str,
|
|
text_bytes: &[u8],
|
|
start: usize,
|
|
sigil: char,
|
|
) -> Option<(&'a str, &'a str, usize)> {
|
|
let sigil_index = start + 1;
|
|
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
|
return None;
|
|
}
|
|
|
|
let name_start = sigil_index + 1;
|
|
let first_name_byte = text_bytes.get(name_start)?;
|
|
if !is_mention_name_char(*first_name_byte) {
|
|
return None;
|
|
}
|
|
|
|
let mut name_end = name_start + 1;
|
|
while let Some(next_byte) = text_bytes.get(name_end)
|
|
&& is_mention_name_char(*next_byte)
|
|
{
|
|
name_end += 1;
|
|
}
|
|
|
|
if text_bytes.get(name_end) != Some(&b']') {
|
|
return None;
|
|
}
|
|
|
|
let mut path_start = name_end + 1;
|
|
while let Some(next_byte) = text_bytes.get(path_start)
|
|
&& next_byte.is_ascii_whitespace()
|
|
{
|
|
path_start += 1;
|
|
}
|
|
if text_bytes.get(path_start) != Some(&b'(') {
|
|
return None;
|
|
}
|
|
|
|
let mut path_end = path_start + 1;
|
|
while let Some(next_byte) = text_bytes.get(path_end)
|
|
&& *next_byte != b')'
|
|
{
|
|
path_end += 1;
|
|
}
|
|
if text_bytes.get(path_end) != Some(&b')') {
|
|
return None;
|
|
}
|
|
|
|
let path = text[path_start + 1..path_end].trim();
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let name = &text[name_start..name_end];
|
|
Some((name, path, path_end + 1))
|
|
}
|
|
|
|
fn is_common_env_var(name: &str) -> bool {
|
|
let upper = name.to_ascii_uppercase();
|
|
matches!(
|
|
upper.as_str(),
|
|
"PATH"
|
|
| "HOME"
|
|
| "USER"
|
|
| "SHELL"
|
|
| "PWD"
|
|
| "TMPDIR"
|
|
| "TEMP"
|
|
| "TMP"
|
|
| "LANG"
|
|
| "TERM"
|
|
| "XDG_CONFIG_HOME"
|
|
)
|
|
}
|
|
|
|
fn is_mention_name_char(byte: u8) -> bool {
|
|
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mentions_tests.rs"]
|
|
mod tests;
|