Move explicit skill selection into the skills crate (#37177)

## What changed

- Add `ExplicitSkillLookup` to decouple explicit mention selection from the
  core skill-loading model.
- Export `collect_explicit_skill_mentions` from `codex-skills` and implement
  the lookup interface for `SkillLoadOutcome`.
- Move the selection tests into `codex-skills` while keeping the prompt-size
  boundary test with prompt injection.

GitOrigin-RevId: 21105af31c9b519c231c5ea02464da560705c293
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-05 23:23:21 +00:00
1 parent 6bb6e9045f
commit a3ebd19fa4
7 files changed
+268 -188

No files matched your search

+1 -168
View File
@@ -1,10 +1,8 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use crate::SkillLoadOutcome;
use crate::SkillMetadata;
use crate::build_skill_name_counts;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::InvocationType;
use codex_analytics::SkillInvocation;
@@ -12,7 +10,6 @@ use codex_analytics::TrackEventsContext;
use codex_exec_server::LOCAL_FS;
use codex_otel::SessionTelemetry;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::user_input::UserInput;
pub use codex_skills::ToolMentionKind;
pub use codex_skills::ToolMentions;
pub use codex_skills::app_id_from_path;
@@ -21,7 +18,6 @@ pub use codex_skills::extract_tool_mentions_with_sigil;
pub use codex_skills::normalize_skill_path;
pub use codex_skills::plugin_config_name_from_path;
pub use codex_skills::tool_kind_for_path;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_string::take_bytes_at_char_boundary;
@@ -177,169 +173,6 @@ fn emit_skill_injected_metric(
);
}
/// Collect explicitly mentioned skills from structured and text mentions.
///
/// Structured `UserInput::Skill` selections are resolved first by path against
/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we
/// iterate loaded skills in their existing order to preserve prior ordering semantics.
/// Explicit paths match either a skill's canonical identity or its logical discovery
/// path, and plain names are only used when the match is unambiguous.
///
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
pub fn collect_explicit_skill_mentions(
inputs: &[UserInput],
loaded_skills: &SkillLoadOutcome,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let skill_name_counts =
build_skill_name_counts(&loaded_skills.skills, &loaded_skills.disabled_paths).0;
let selection_context = SkillSelectionContext {
loaded_skills,
skill_name_counts: &skill_name_counts,
connector_slug_counts,
};
let mut selected: Vec<SkillMetadata> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
let mut seen_paths: HashSet<AbsolutePathBuf> = HashSet::new();
let mut blocked_plain_names: HashSet<String> = HashSet::new();
for input in inputs {
if let UserInput::Skill { name, path, .. } = input {
blocked_plain_names.insert(name.clone());
let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else {
continue;
};
let Some(skill) = selection_context.loaded_skills.skills.iter().find(|skill| {
skill.path_to_skills_md == path
|| selection_context
.loaded_skills
.skill_discovery_path_for_path(&skill.path_to_skills_md)
.is_some_and(|discovery_path| discovery_path == &path)
}) else {
continue;
};
if !selection_context.loaded_skills.is_skill_enabled(skill)
|| seen_paths.contains(&skill.path_to_skills_md)
{
continue;
}
seen_paths.insert(skill.path_to_skills_md.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
}
for input in inputs {
if let UserInput::Text { text, .. } = input {
let mentioned_names = extract_tool_mentions(text);
select_skills_from_mentions(
&selection_context,
&blocked_plain_names,
&mentioned_names,
&mut seen_names,
&mut seen_paths,
&mut selected,
);
}
}
selected
}
struct SkillSelectionContext<'a> {
loaded_skills: &'a SkillLoadOutcome,
skill_name_counts: &'a HashMap<String, usize>,
connector_slug_counts: &'a HashMap<String, usize>,
}
/// Select mentioned skills while preserving the order of `skills`.
fn select_skills_from_mentions(
selection_context: &SkillSelectionContext<'_>,
blocked_plain_names: &HashSet<String>,
mentions: &ToolMentions<'_>,
seen_names: &mut HashSet<String>,
seen_paths: &mut HashSet<AbsolutePathBuf>,
selected: &mut Vec<SkillMetadata>,
) {
if mentions.is_empty() {
return;
}
let mention_skill_paths: HashSet<String> = mentions
.paths()
.filter(|path| {
!matches!(
tool_kind_for_path(path),
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
)
})
.map(normalize_host_skill_path)
.collect();
for skill in &selection_context.loaded_skills.skills {
if !selection_context.loaded_skills.is_skill_enabled(skill)
|| seen_paths.contains(&skill.path_to_skills_md)
{
continue;
}
let canonical_path = normalize_host_skill_path(&skill.path_to_skills_md.to_string_lossy());
let matches_discovery_path = selection_context
.loaded_skills
.skill_discovery_path_for_path(&skill.path_to_skills_md)
.is_some_and(|discovery_path| {
mention_skill_paths.contains(&normalize_host_skill_path(
&discovery_path.to_string_lossy(),
))
});
if mention_skill_paths.contains(&canonical_path) || matches_discovery_path {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
}
for skill in &selection_context.loaded_skills.skills {
if !selection_context.loaded_skills.is_skill_enabled(skill)
|| seen_paths.contains(&skill.path_to_skills_md)
{
continue;
}
if blocked_plain_names.contains(skill.name.as_str()) {
continue;
}
if !mentions.contains_plain_name(skill.name.as_str()) {
continue;
}
let skill_count = selection_context
.skill_name_counts
.get(skill.name.as_str())
.copied()
.unwrap_or(0);
let connector_count = selection_context
.connector_slug_counts
.get(&skill.name.to_ascii_lowercase())
.copied()
.unwrap_or(0);
if skill_count != 1 || connector_count != 0 {
continue;
}
if seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path_to_skills_md.clone());
selected.push(skill.clone());
}
}
}
#[cfg(test)]
#[path = "injection_tests.rs"]
#[path = "prompt_injection_tests.rs"]
mod tests;
-347
View File
@@ -1,347 +0,0 @@
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
fn make_skill(name: &str, path: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
description: format!("{name} skill"),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf(path).abs(),
scope: codex_protocol::protocol::SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
}
}
#[test]
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
let (bounded, truncated) = bounded_skill_prompt_contents(&contents);
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
assert_eq!(truncated, true);
}
fn linked_skill_mention(name: &str, unix_path: &str) -> String {
format!("[${name}]({})", test_path_buf(unix_path).display())
}
fn collect_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let loaded_skills = SkillLoadOutcome {
skills: skills.to_vec(),
disabled_paths: disabled_paths.clone(),
..Default::default()
};
collect_explicit_skill_mentions(inputs, &loaded_skills, connector_slug_counts)
}
fn skill_outcome_with_discovery_path(
skill: SkillMetadata,
discovery_path: &str,
) -> SkillLoadOutcome {
SkillLoadOutcome {
skill_discovery_path_by_path: Arc::new(HashMap::from([(
skill.path_to_skills_md.clone(),
test_path_buf(discovery_path).abs(),
)])),
skills: vec![skill],
..Default::default()
}
}
#[test]
fn collect_explicit_skill_mentions_text_respects_skill_order() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let beta = make_skill("beta-skill", "/tmp/beta");
let skills = vec![beta.clone(), alpha.clone()];
let inputs = vec![UserInput::Text {
text: "first $alpha-skill then $beta-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
// Text scanning should not change the previous selection ordering semantics.
assert_eq!(selected, vec![beta, alpha]);
}
#[test]
fn collect_explicit_skill_mentions_prioritizes_structured_inputs() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let beta = make_skill("beta-skill", "/tmp/beta");
let skills = vec![alpha.clone(), beta.clone()];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "beta-skill".to_string(),
path: test_path_buf("/tmp/beta"),
},
];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![beta, alpha]);
}
#[test]
fn collect_explicit_skill_mentions_accepts_structured_discovery_path() {
let skill = make_skill("linked-skill", "/tmp/shared/linked-skill/SKILL.md");
let loaded_skills = skill_outcome_with_discovery_path(
skill.clone(),
"/tmp/project/.agents/skills/linked-skill/SKILL.md",
);
let inputs = vec![UserInput::Skill {
name: "linked-skill".to_string(),
path: test_path_buf("/tmp/project/.agents/skills/linked-skill/SKILL.md"),
}];
let selected = collect_explicit_skill_mentions(&inputs, &loaded_skills, &HashMap::new());
assert_eq!(selected, vec![skill]);
}
#[test]
fn collect_explicit_skill_mentions_accepts_linked_discovery_path() {
let skill = make_skill("linked-skill", "/tmp/shared/linked-skill/SKILL.md");
let loaded_skills = skill_outcome_with_discovery_path(
skill.clone(),
"/tmp/project/.agents/skills/linked-skill/SKILL.md",
);
let inputs = vec![UserInput::Text {
text: linked_skill_mention(
"linked-skill",
"/tmp/project/.agents/skills/linked-skill/SKILL.md",
),
text_elements: Vec::new(),
}];
let selected = collect_explicit_skill_mentions(&inputs, &loaded_skills, &HashMap::new());
assert_eq!(selected, vec![skill]);
}
#[test]
fn collect_explicit_skill_mentions_rejects_disabled_discovery_path() {
let skill = make_skill("linked-skill", "/tmp/shared/linked-skill/SKILL.md");
let mut loaded_skills = skill_outcome_with_discovery_path(
skill.clone(),
"/tmp/project/.agents/skills/linked-skill/SKILL.md",
);
loaded_skills.disabled_paths.insert(skill.path_to_skills_md);
let inputs = vec![UserInput::Skill {
name: "linked-skill".to_string(),
path: test_path_buf("/tmp/project/.agents/skills/linked-skill/SKILL.md"),
}];
let selected = collect_explicit_skill_mentions(&inputs, &loaded_skills, &HashMap::new());
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "alpha-skill".to_string(),
path: test_path_buf("/tmp/missing"),
},
];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "alpha-skill".to_string(),
path: test_path_buf("/tmp/alpha"),
},
];
let disabled = HashSet::from([test_path_buf("/tmp/alpha").abs()]);
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_dedupes_by_path() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha.clone()];
let mention = linked_skill_mention("alpha-skill", "/tmp/alpha");
let inputs = vec![UserInput::Text {
text: format!("use {mention} and {mention}"),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
}
#[test]
fn collect_explicit_skill_mentions_skips_ambiguous_name() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: "use $demo-skill and again $demo-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_prefers_linked_path_over_name() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta.clone()];
let inputs = vec![UserInput::Text {
text: format!(
"use $demo-skill and {}",
linked_skill_mention("demo-skill", "/tmp/beta")
),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![beta]);
}
#[test]
fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![UserInput::Text {
text: "use $alpha-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha.clone()];
let inputs = vec![UserInput::Text {
text: format!("use {}", linked_skill_mention("alpha-skill", "/tmp/alpha")),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
}
#[test]
fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/alpha")),
text_elements: Vec::new(),
}];
let disabled = HashSet::from([test_path_buf("/tmp/alpha").abs()]);
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_prefers_resource_path() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta.clone()];
let inputs = vec![UserInput::Text {
text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/beta")),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![beta]);
}
#[test]
fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/missing")),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![UserInput::Text {
text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/missing")),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
+18
View File
@@ -131,6 +131,24 @@ impl codex_skills::ImplicitSkillLookup for SkillLoadOutcome {
}
}
impl codex_skills::ExplicitSkillLookup for SkillLoadOutcome {
fn skills(&self) -> &[SkillMetadata] {
&self.skills
}
fn disabled_paths(&self) -> &HashSet<AbsolutePathBuf> {
&self.disabled_paths
}
fn skill_discovery_path_for_path(&self, path: &AbsolutePathBuf) -> Option<&AbsolutePathBuf> {
SkillLoadOutcome::skill_discovery_path_for_path(self, path)
}
fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
SkillLoadOutcome::is_skill_enabled(self, skill)
}
}
#[derive(Clone, Default)]
pub(crate) struct SkillFileSystemsByPath {
values: Arc<HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>>>,
@@ -0,0 +1,14 @@
use pretty_assertions::assert_eq;
use super::MAX_SKILL_PROMPT_BYTES;
use super::bounded_skill_prompt_contents;
#[test]
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
let (bounded, truncated) = bounded_skill_prompt_contents(&contents);
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
assert_eq!(truncated, true);
}