Files
codex/codex-rs/ext/skills/src/selection.rs
T
jif 96d2d2f68c Implement v1 skills extension prompt injection (#26167)
## Why

The skills extension needs a real turn-time path before host, executor,
or remote skills can be routed through it. The previous code was mostly
a placeholder catalog/provider sketch, so there was no bounded
available-skills fragment, no source-owned `SKILL.md` read, and no place
for warnings or per-turn selection state to live.

This PR makes `ext/skills` the authority-preserving flow for listing
candidate skills and injecting only explicitly selected main prompts,
without adding more of that logic to `codex-core`.

## What changed

- Expands catalog entries with `main_prompt`, display path, short
description, dependency metadata, enabled/prompt visibility flags, and
authority/package-aware read requests.
- Replaces the placeholder `providers/*` modules with
`SkillProviderSource` and `SkillProviders`, routing list/read/search
calls by source kind and surfacing provider failures as warnings.
- Adds bounded available-skills rendering and `SKILL.md` main-prompt
truncation before the fragments enter model context.
- Resolves explicit skill selections from structured `UserInput::Skill`,
skill-file mentions, `skill://...` paths, and plain `$skill` text
mentions, then reads selected prompts through their owning provider.
- Stores mutable per-thread skills config and per-turn
catalog/selection/warning state.
- Adds `install_with_providers` so tests and future host wiring can
supply concrete providers.

## Testing

- Not run locally.
- Added `codex-rs/ext/skills/tests/skills_extension.rs` coverage for
available-catalog injection, selected prompt injection through the
owning provider, and prompt-hidden skills that remain invokable.
2026-06-03 16:24:16 +02:00

130 lines
3.7 KiB
Rust

use std::collections::HashSet;
use codex_core_skills::injection::extract_tool_mentions;
use codex_protocol::user_input::UserInput;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
const SKILL_PATH_PREFIX: &str = "skill://";
pub(crate) fn collect_explicit_skill_mentions(
inputs: &[UserInput],
catalog: &SkillCatalog,
) -> Vec<SkillCatalogEntry> {
let mut selected = Vec::new();
let mut seen = HashSet::new();
let mut blocked_plain_names = HashSet::new();
for input in inputs {
match input {
UserInput::Skill { name, path } => {
blocked_plain_names.insert(name.clone());
select_by_path(catalog, &path.to_string_lossy(), &mut seen, &mut selected);
}
UserInput::Mention { name, path } if path_is_skill(path) => {
blocked_plain_names.insert(name.clone());
select_by_path(catalog, path, &mut seen, &mut selected);
}
UserInput::Text { .. } | UserInput::Image { .. } | UserInput::LocalImage { .. } => {}
UserInput::Mention { .. } => {}
_ => {}
}
}
for input in inputs {
let UserInput::Text { text, .. } = input else {
continue;
};
let mentions = extract_tool_mentions(text);
for path in mentions.paths() {
if path_is_skill(path) {
select_by_path(
catalog,
normalize_skill_path(path),
&mut seen,
&mut selected,
);
}
}
for name in mentions.plain_names() {
if blocked_plain_names.contains(name) {
continue;
}
if let Some(entry) = catalog
.entries
.iter()
.find(|entry| entry.enabled && entry.name == name)
{
push_selected(entry, &mut seen, &mut selected);
}
}
}
selected
}
fn select_by_path(
catalog: &SkillCatalog,
path: &str,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let normalized_path = normalize_skill_path(path);
for entry in catalog.entries.iter().filter(|entry| entry.enabled) {
if entry_matches_path(entry, normalized_path) {
push_selected(entry, seen, selected);
}
}
}
fn push_selected(
entry: &SkillCatalogEntry,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let key = SkillCatalogEntryKey::from(entry);
if seen.insert(key) {
selected.push(entry.clone());
}
}
fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool {
entry.main_prompt.0 == path
|| entry.id.0 == path
|| entry
.display_path
.as_deref()
.is_some_and(|display_path| display_path == path)
}
fn path_is_skill(path: &str) -> bool {
path.starts_with(SKILL_PATH_PREFIX)
|| path
.rsplit(['/', '\\'])
.next()
.is_some_and(|file_name| file_name.eq_ignore_ascii_case("SKILL.md"))
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct SkillCatalogEntryKey {
authority: SkillAuthority,
package: SkillPackageId,
}
impl From<&SkillCatalogEntry> for SkillCatalogEntryKey {
fn from(entry: &SkillCatalogEntry) -> Self {
Self {
authority: entry.authority.clone(),
package: entry.id.clone(),
}
}
}