Skip to content

Commit a3ebd19

Browse files
felixxia-oaicopyberry
authored andcommitted
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
1 parent 6bb6e90 commit a3ebd19

7 files changed

Lines changed: 268 additions & 188 deletions

File tree

Lines changed: 1 addition & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
1-
use std::collections::HashMap;
21
use std::collections::HashSet;
32
use std::sync::Arc;
43

54
use crate::SkillLoadOutcome;
65
use crate::SkillMetadata;
7-
use crate::build_skill_name_counts;
86
use codex_analytics::AnalyticsEventsClient;
97
use codex_analytics::InvocationType;
108
use codex_analytics::SkillInvocation;
119
use codex_analytics::TrackEventsContext;
1210
use codex_exec_server::LOCAL_FS;
1311
use codex_otel::SessionTelemetry;
1412
use codex_otel::sanitize_metric_tag_value;
15-
use codex_protocol::user_input::UserInput;
1613
pub use codex_skills::ToolMentionKind;
1714
pub use codex_skills::ToolMentions;
1815
pub use codex_skills::app_id_from_path;
@@ -21,7 +18,6 @@ pub use codex_skills::extract_tool_mentions_with_sigil;
2118
pub use codex_skills::normalize_skill_path;
2219
pub use codex_skills::plugin_config_name_from_path;
2320
pub use codex_skills::tool_kind_for_path;
24-
use codex_utils_absolute_path::AbsolutePathBuf;
2521
use codex_utils_path_uri::PathUri;
2622
use codex_utils_string::take_bytes_at_char_boundary;
2723

@@ -177,169 +173,6 @@ fn emit_skill_injected_metric(
177173
);
178174
}
179175

180-
/// Collect explicitly mentioned skills from structured and text mentions.
181-
///
182-
/// Structured `UserInput::Skill` selections are resolved first by path against
183-
/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we
184-
/// iterate loaded skills in their existing order to preserve prior ordering semantics.
185-
/// Explicit paths match either a skill's canonical identity or its logical discovery
186-
/// path, and plain names are only used when the match is unambiguous.
187-
///
188-
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
189-
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
190-
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
191-
pub fn collect_explicit_skill_mentions(
192-
inputs: &[UserInput],
193-
loaded_skills: &SkillLoadOutcome,
194-
connector_slug_counts: &HashMap<String, usize>,
195-
) -> Vec<SkillMetadata> {
196-
let skill_name_counts =
197-
build_skill_name_counts(&loaded_skills.skills, &loaded_skills.disabled_paths).0;
198-
199-
let selection_context = SkillSelectionContext {
200-
loaded_skills,
201-
skill_name_counts: &skill_name_counts,
202-
connector_slug_counts,
203-
};
204-
let mut selected: Vec<SkillMetadata> = Vec::new();
205-
let mut seen_names: HashSet<String> = HashSet::new();
206-
let mut seen_paths: HashSet<AbsolutePathBuf> = HashSet::new();
207-
let mut blocked_plain_names: HashSet<String> = HashSet::new();
208-
209-
for input in inputs {
210-
if let UserInput::Skill { name, path, .. } = input {
211-
blocked_plain_names.insert(name.clone());
212-
let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else {
213-
continue;
214-
};
215-
216-
let Some(skill) = selection_context.loaded_skills.skills.iter().find(|skill| {
217-
skill.path_to_skills_md == path
218-
|| selection_context
219-
.loaded_skills
220-
.skill_discovery_path_for_path(&skill.path_to_skills_md)
221-
.is_some_and(|discovery_path| discovery_path == &path)
222-
}) else {
223-
continue;
224-
};
225-
226-
if !selection_context.loaded_skills.is_skill_enabled(skill)
227-
|| seen_paths.contains(&skill.path_to_skills_md)
228-
{
229-
continue;
230-
}
231-
232-
seen_paths.insert(skill.path_to_skills_md.clone());
233-
seen_names.insert(skill.name.clone());
234-
selected.push(skill.clone());
235-
}
236-
}
237-
238-
for input in inputs {
239-
if let UserInput::Text { text, .. } = input {
240-
let mentioned_names = extract_tool_mentions(text);
241-
select_skills_from_mentions(
242-
&selection_context,
243-
&blocked_plain_names,
244-
&mentioned_names,
245-
&mut seen_names,
246-
&mut seen_paths,
247-
&mut selected,
248-
);
249-
}
250-
}
251-
252-
selected
253-
}
254-
255-
struct SkillSelectionContext<'a> {
256-
loaded_skills: &'a SkillLoadOutcome,
257-
skill_name_counts: &'a HashMap<String, usize>,
258-
connector_slug_counts: &'a HashMap<String, usize>,
259-
}
260-
261-
/// Select mentioned skills while preserving the order of `skills`.
262-
fn select_skills_from_mentions(
263-
selection_context: &SkillSelectionContext<'_>,
264-
blocked_plain_names: &HashSet<String>,
265-
mentions: &ToolMentions<'_>,
266-
seen_names: &mut HashSet<String>,
267-
seen_paths: &mut HashSet<AbsolutePathBuf>,
268-
selected: &mut Vec<SkillMetadata>,
269-
) {
270-
if mentions.is_empty() {
271-
return;
272-
}
273-
274-
let mention_skill_paths: HashSet<String> = mentions
275-
.paths()
276-
.filter(|path| {
277-
!matches!(
278-
tool_kind_for_path(path),
279-
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
280-
)
281-
})
282-
.map(normalize_host_skill_path)
283-
.collect();
284-
285-
for skill in &selection_context.loaded_skills.skills {
286-
if !selection_context.loaded_skills.is_skill_enabled(skill)
287-
|| seen_paths.contains(&skill.path_to_skills_md)
288-
{
289-
continue;
290-
}
291-
292-
let canonical_path = normalize_host_skill_path(&skill.path_to_skills_md.to_string_lossy());
293-
let matches_discovery_path = selection_context
294-
.loaded_skills
295-
.skill_discovery_path_for_path(&skill.path_to_skills_md)
296-
.is_some_and(|discovery_path| {
297-
mention_skill_paths.contains(&normalize_host_skill_path(
298-
&discovery_path.to_string_lossy(),
299-
))
300-
});
301-
if mention_skill_paths.contains(&canonical_path) || matches_discovery_path {
302-
seen_paths.insert(skill.path_to_skills_md.clone());
303-
seen_names.insert(skill.name.clone());
304-
selected.push(skill.clone());
305-
}
306-
}
307-
308-
for skill in &selection_context.loaded_skills.skills {
309-
if !selection_context.loaded_skills.is_skill_enabled(skill)
310-
|| seen_paths.contains(&skill.path_to_skills_md)
311-
{
312-
continue;
313-
}
314-
315-
if blocked_plain_names.contains(skill.name.as_str()) {
316-
continue;
317-
}
318-
if !mentions.contains_plain_name(skill.name.as_str()) {
319-
continue;
320-
}
321-
322-
let skill_count = selection_context
323-
.skill_name_counts
324-
.get(skill.name.as_str())
325-
.copied()
326-
.unwrap_or(0);
327-
let connector_count = selection_context
328-
.connector_slug_counts
329-
.get(&skill.name.to_ascii_lowercase())
330-
.copied()
331-
.unwrap_or(0);
332-
if skill_count != 1 || connector_count != 0 {
333-
continue;
334-
}
335-
336-
if seen_names.insert(skill.name.clone()) {
337-
seen_paths.insert(skill.path_to_skills_md.clone());
338-
selected.push(skill.clone());
339-
}
340-
}
341-
}
342-
343176
#[cfg(test)]
344-
#[path = "injection_tests.rs"]
177+
#[path = "prompt_injection_tests.rs"]
345178
mod tests;

codex-rs/core-skills/src/model.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,24 @@ impl codex_skills::ImplicitSkillLookup for SkillLoadOutcome {
131131
}
132132
}
133133

134+
impl codex_skills::ExplicitSkillLookup for SkillLoadOutcome {
135+
fn skills(&self) -> &[SkillMetadata] {
136+
&self.skills
137+
}
138+
139+
fn disabled_paths(&self) -> &HashSet<AbsolutePathBuf> {
140+
&self.disabled_paths
141+
}
142+
143+
fn skill_discovery_path_for_path(&self, path: &AbsolutePathBuf) -> Option<&AbsolutePathBuf> {
144+
SkillLoadOutcome::skill_discovery_path_for_path(self, path)
145+
}
146+
147+
fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
148+
SkillLoadOutcome::is_skill_enabled(self, skill)
149+
}
150+
}
151+
134152
#[derive(Clone, Default)]
135153
pub(crate) struct SkillFileSystemsByPath {
136154
values: Arc<HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>>>,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use pretty_assertions::assert_eq;
2+
3+
use super::MAX_SKILL_PROMPT_BYTES;
4+
use super::bounded_skill_prompt_contents;
5+
6+
#[test]
7+
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
8+
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
9+
10+
let (bounded, truncated) = bounded_skill_prompt_contents(&contents);
11+
12+
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
13+
assert_eq!(truncated, true);
14+
}

codex-rs/core/src/skills.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ pub use codex_core_skills::filter_skill_load_outcome_for_product;
2222
pub use codex_core_skills::injection;
2323
pub use codex_core_skills::injection::SkillInjections;
2424
pub use codex_core_skills::injection::build_skill_injections;
25-
pub use codex_core_skills::injection::collect_explicit_skill_mentions;
2625
pub use codex_core_skills::loader;
2726
pub use codex_core_skills::model;
2827
pub use codex_core_skills::remote;
2928
pub use codex_skills::SkillMetadata;
3029
pub use codex_skills::SkillPolicy;
30+
pub use codex_skills::collect_explicit_skill_mentions;
3131
pub use codex_skills_extension::HostSkillsLoadInput;
3232
pub use codex_skills_extension::HostSkillsService;
3333
pub use codex_skills_extension::bundled_skills_enabled_from_stack;

codex-rs/skills/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod model;
55
mod name_counts;
66
mod parser;
77
mod policy;
8+
mod selection;
89

910
pub use interface::SkillInterfaceAssetPolicy;
1011
pub use interface::SkillInterfaceFile;
@@ -33,6 +34,8 @@ pub use parser::ParsedSkillFrontmatter;
3334
pub use parser::SkillParseError;
3435
pub use parser::parse_skill_frontmatter_metadata;
3536
pub use policy::resolve_disabled_skill_paths;
37+
pub use selection::ExplicitSkillLookup;
38+
pub use selection::collect_explicit_skill_mentions;
3639

3740
use codex_utils_absolute_path::AbsolutePathBuf;
3841
use include_dir::Dir;

0 commit comments

Comments
 (0)