Share runtime skill rendering and selection

This commit is contained in:
jif-oai
2026-06-25 17:41:49 +01:00
parent 6414d3a42f
commit c6f639becb
28 changed files with 1089 additions and 3358 deletions
-6
View File
@@ -8,7 +8,6 @@ use codex_plugin::PluginCapabilitySummary;
/// Connector declarations contributed by one plugin package.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginConnectorSource {
plugin_id: String,
plugin_display_name: String,
connector_ids: Vec<AppConnectorId>,
}
@@ -16,12 +15,10 @@ pub struct PluginConnectorSource {
impl PluginConnectorSource {
/// Creates one plugin source from parsed app declarations.
pub fn new(
plugin_id: impl Into<String>,
plugin_display_name: impl Into<String>,
declarations: impl IntoIterator<Item = AppDeclaration>,
) -> Self {
Self::from_connector_ids(
plugin_id,
plugin_display_name,
declarations
.into_iter()
@@ -31,7 +28,6 @@ impl PluginConnectorSource {
/// Creates one plugin source from connector IDs that were already parsed.
pub fn from_connector_ids(
plugin_id: impl Into<String>,
plugin_display_name: impl Into<String>,
connector_ids: impl IntoIterator<Item = AppConnectorId>,
) -> Self {
@@ -42,7 +38,6 @@ impl PluginConnectorSource {
.filter(|connector_id| seen_connector_ids.insert(connector_id.clone()))
.collect();
Self {
plugin_id: plugin_id.into(),
plugin_display_name: plugin_display_name.into(),
connector_ids,
}
@@ -105,7 +100,6 @@ impl ConnectorSnapshot {
pub fn from_plugin_capability_summaries(summaries: &[PluginCapabilitySummary]) -> Self {
Self::from_plugin_sources(summaries.iter().map(|summary| {
PluginConnectorSource::from_connector_ids(
summary.config_name.clone(),
summary.display_name.clone(),
summary.app_connector_ids.clone(),
)
+6 -9
View File
@@ -6,14 +6,12 @@ use super::PluginConnectorSource;
#[test]
fn snapshot_merges_sources_in_order_and_dedupes_provenance() {
let host_source = source("host", "Zulu", &["calendar", "calendar"]);
let host = ConnectorSnapshot::from_plugin_sources([
source("skills", "Skills only", &[]),
host_source.clone(),
]);
let host_source = source("Zulu", &["calendar", "calendar"]);
let host =
ConnectorSnapshot::from_plugin_sources([source("Skills only", &[]), host_source.clone()]);
let selected = ConnectorSnapshot::from_plugin_sources([
source("selected-a", "Alpha", &["drive", "calendar"]),
source("selected-b", "Alpha", &["calendar"]),
source("Alpha", &["drive", "calendar"]),
source("Alpha", &["calendar"]),
]);
let merged = host.merged_with(&selected);
@@ -36,9 +34,8 @@ fn snapshot_merges_sources_in_order_and_dedupes_provenance() {
);
}
fn source(id: &str, display_name: &str, connector_ids: &[&str]) -> PluginConnectorSource {
fn source(display_name: &str, connector_ids: &[&str]) -> PluginConnectorSource {
PluginConnectorSource::from_connector_ids(
id,
display_name,
connector_ids
.iter()
+2 -341
View File
@@ -1,228 +1,13 @@
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;
use codex_analytics::TrackEventsContext;
use codex_exec_server::LOCAL_FS;
use codex_otel::SessionTelemetry;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
#[derive(Debug, Default)]
pub struct SkillInjections {
pub items: Vec<SkillInjection>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillInjection {
pub name: String,
pub path: String,
pub contents: String,
}
/// Host skill prompts that have already been injected by an extension for this
/// turn.
///
/// Core uses this to keep the legacy skill-injection path from sending the same
/// host `SKILL.md` body again while the skills extension is being wired in.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InjectedHostSkillPrompts {
paths: HashSet<String>,
}
impl InjectedHostSkillPrompts {
pub fn insert_path(&mut self, path: impl Into<String>) {
let path = path.into();
self.paths.insert(normalize_host_skill_path(&path));
self.paths.insert(path);
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn contains_path(&self, path: &str) -> bool {
self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path))
}
}
#[tracing::instrument(
level = "trace",
skip_all,
fields(mentioned_skill_count = mentioned_skills.len())
)]
pub async fn build_skill_injections(
mentioned_skills: &[SkillMetadata],
loaded_skills: Option<&SkillLoadOutcome>,
otel: Option<&SessionTelemetry>,
analytics_client: &AnalyticsEventsClient,
tracking: TrackEventsContext,
) -> SkillInjections {
if mentioned_skills.is_empty() {
return SkillInjections::default();
}
let mut result = SkillInjections {
items: Vec::with_capacity(mentioned_skills.len()),
warnings: Vec::new(),
};
let mut invocations = Vec::new();
for skill in mentioned_skills {
let fs = loaded_skills
.and_then(|outcome| outcome.file_system_for_skill(skill))
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
let path = PathUri::from_abs_path(&skill.path_to_skills_md);
match fs.read_file_text(&path, /*sandbox*/ None).await {
Ok(contents) => {
emit_skill_injected_metric(otel, skill, "ok");
invocations.push(SkillInvocation {
skill_name: skill.name.clone(),
skill_scope: skill.scope,
skill_path: skill.path_to_skills_md.to_path_buf(),
plugin_id: skill.plugin_id.clone(),
invocation_type: InvocationType::Explicit,
});
result.items.push(SkillInjection {
name: skill.name.clone(),
path: skill.path_to_skills_md.to_string_lossy().into_owned(),
contents,
});
}
Err(err) => {
emit_skill_injected_metric(otel, skill, "error");
let message = format!(
"Failed to load skill {name} at {path}: {err:#}",
name = skill.name,
path = skill.path_to_skills_md.display()
);
result.warnings.push(message);
}
}
}
analytics_client.track_skill_invocations(tracking, invocations);
result
}
fn normalize_host_skill_path(path: &str) -> String {
normalize_skill_path(path).replace('\\', "/")
}
fn emit_skill_injected_metric(
otel: Option<&SessionTelemetry>,
skill: &SkillMetadata,
status: &str,
) {
let Some(otel) = otel else {
return;
};
otel.counter(
"codex.skill.injected",
/*inc*/ 1,
&[("status", status), ("skill", skill.name.as_str())],
);
}
/// 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 `skills` in their existing order to preserve prior ordering semantics.
/// Explicit links are resolved by 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],
skills: &[SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let skill_name_counts = build_skill_name_counts(skills, disabled_paths).0;
let selection_context = SkillSelectionContext {
skills,
disabled_paths,
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;
};
if selection_context.disabled_paths.contains(&path) || seen_paths.contains(&path) {
continue;
}
if let Some(skill) = selection_context
.skills
.iter()
.find(|skill| skill.path_to_skills_md == path)
{
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> {
skills: &'a [SkillMetadata],
disabled_paths: &'a HashSet<AbsolutePathBuf>,
skill_name_counts: &'a HashMap<String, usize>,
connector_slug_counts: &'a HashMap<String, usize>,
}
pub struct ToolMentions<'a> {
names: HashSet<&'a str>,
paths: HashSet<&'a str>,
plain_names: HashSet<&'a str>,
}
impl<'a> ToolMentions<'a> {
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()
}
@@ -276,22 +61,16 @@ pub fn plugin_config_name_from_path(path: &str) -> Option<&str> {
.filter(|value| !value.is_empty())
}
pub(crate) 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.
/// Supports explicit resource links in the form `[$tool-name](resource path)`.
/// Linked mentions are selected by their exact path rather than falling back to the name.
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();
@@ -303,12 +82,6 @@ pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions
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;
@@ -339,97 +112,17 @@ pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions
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,
}
}
/// 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<&str> = mentions
.paths()
.filter(|path| {
!matches!(
tool_kind_for_path(path),
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
)
})
.map(normalize_skill_path)
.collect();
for skill in selection_context.skills {
if selection_context
.disabled_paths
.contains(&skill.path_to_skills_md)
|| seen_paths.contains(&skill.path_to_skills_md)
{
continue;
}
let path_str = skill.path_to_skills_md.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
}
for skill in selection_context.skills {
if selection_context
.disabled_paths
.contains(&skill.path_to_skills_md)
|| seen_paths.contains(&skill.path_to_skills_md)
{
continue;
}
if blocked_plain_names.contains(skill.name.as_str()) {
continue;
}
if !mentions.plain_names.contains(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());
}
}
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
@@ -505,38 +198,6 @@ fn is_common_env_var(name: &str) -> bool {
)
}
#[cfg(test)]
fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
if skill_name.is_empty() {
return false;
}
let text_bytes = text.as_bytes();
let skill_bytes = skill_name.as_bytes();
for (index, byte) in text_bytes.iter().copied().enumerate() {
if byte != b'$' {
continue;
}
let name_start = index + 1;
let Some(rest) = text_bytes.get(name_start..) else {
continue;
};
if !rest.starts_with(skill_bytes) {
continue;
}
let after_index = name_start + skill_bytes.len();
let after = text_bytes.get(after_index).copied();
if after.is_none_or(|b| !is_mention_name_char(b)) {
return true;
}
}
false
}
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':')
}
+2 -301
View File
@@ -1,94 +1,22 @@
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;
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,
}
}
fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> {
items.iter().copied().collect()
}
fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) {
let mentions = extract_tool_mentions(text);
assert_eq!(mentions.names, set(expected_names));
assert_eq!(mentions.plain_names, set(expected_names));
assert_eq!(mentions.paths, set(expected_paths));
}
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> {
collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts)
}
#[test]
fn text_mentions_skill_requires_exact_boundary() {
assert_eq!(
true,
text_mentions_skill("use $notion-research-doc please", "notion-research-doc")
);
assert_eq!(
true,
text_mentions_skill("($notion-research-doc)", "notion-research-doc")
);
assert_eq!(
true,
text_mentions_skill("$notion-research-doc.", "notion-research-doc")
);
assert_eq!(
false,
text_mentions_skill("$notion-research-docs", "notion-research-doc")
);
assert_eq!(
false,
text_mentions_skill("$notion-research-doc_extra", "notion-research-doc")
);
}
#[test]
fn text_mentions_skill_handles_end_boundary_and_near_misses() {
assert_eq!(true, text_mentions_skill("$alpha-skill", "alpha-skill"));
assert_eq!(false, text_mentions_skill("$alpha-skillx", "alpha-skill"));
assert_eq!(
true,
text_mentions_skill("$alpha-skillx and later $alpha-skill ", "alpha-skill")
);
}
#[test]
fn text_mentions_skill_handles_many_dollars_without_looping() {
let prefix = "$".repeat(256);
let text = format!("{prefix} not-a-mention");
assert_eq!(false, text_mentions_skill(&text, "alpha-skill"));
}
#[test]
fn extract_tool_mentions_handles_plain_and_linked_mentions() {
assert_mentions(
"use $alpha and [$beta](/tmp/beta)",
&["alpha", "beta"],
&["alpha"],
&["/tmp/beta"],
);
}
@@ -129,230 +57,3 @@ fn extract_tool_mentions_keeps_plugin_skill_namespaces() {
&[],
);
}
#[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_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());
}
+4 -2
View File
@@ -8,8 +8,8 @@ pub mod remote;
pub mod render;
mod root_loader;
pub mod runtime;
mod runtime_selection;
pub mod service;
mod skill_instructions;
pub mod system;
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
@@ -28,10 +28,12 @@ pub use render::SKILLS_INTRO_WITH_ABSOLUTE_PATHS;
pub use render::SKILLS_INTRO_WITH_ALIASES;
pub use render::SkillMetadataBudget;
pub use render::SkillRenderReport;
pub use render::SkillRenderSideEffects;
pub use render::build_available_skills;
pub use render::build_available_skills_from_catalog;
pub use render::default_skill_metadata_budget;
pub use render::render_available_skills_body;
pub use root_loader::PluginSkillSnapshots;
pub use runtime_selection::collect_runtime_skill_mentions;
pub use service::SkillsLoadInput;
pub use service::SkillsService;
pub use skill_instructions::SkillInstructions;
+284 -15
View File
@@ -6,6 +6,9 @@ use std::path::Path;
use crate::model::SkillLoadOutcome;
use crate::model::SkillMetadata;
use crate::runtime::SkillCatalog;
use crate::runtime::SkillCatalogEntry;
use crate::runtime::SkillSourceKind;
use codex_otel::SessionTelemetry;
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC;
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
@@ -16,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
const MAX_RUNTIME_SKILL_METADATA_BYTES: usize = 8_000;
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS: usize = 1_024;
const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "...";
@@ -25,9 +29,9 @@ pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were s
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
"Exceeded skills context budget. All skill descriptions were removed and";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `environment resource` locators are owned by an execution environment, `orchestrator resource` locators are opaque non-filesystem resources, and `custom resource` locators use their provider's access mechanism.";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `environment resource` locators are owned by an execution environment, and `orchestrator resource` locators are opaque non-filesystem resources.";
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` entries are owned by their execution environment, `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`, and `custom resource` entries use their provider's access mechanism.
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` entries are owned by their execution environment, and `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or its source can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
@@ -202,6 +206,103 @@ pub fn build_available_skills(
Some(selected)
}
/// Renders one authority-aware runtime catalog with the same budget, report,
/// warnings, aliases, and metrics as host-only skills.
pub fn build_available_skills_from_catalog(
catalog: &SkillCatalog,
host_outcome: Option<&SkillLoadOutcome>,
budget: SkillMetadataBudget,
side_effects: SkillRenderSideEffects<'_>,
) -> Option<AvailableSkills> {
let mut entries = catalog
.entries
.iter()
.filter(|entry| entry.enabled && entry.prompt_visible)
.collect::<Vec<_>>();
if entries.is_empty() {
record_skill_render_side_effects(
side_effects,
/*total_count*/ 0,
/*included_count*/ 0,
/*omitted_count*/ 0,
/*truncated_description_chars*/ 0,
);
return None;
}
if let Some(outcome) = host_outcome {
let host_paths =
ordered_skills_for_budget(&outcome.allowed_skills_for_implicit_invocation())
.into_iter()
.map(|skill| {
normalized_path(&skill.path_to_skills_md.to_string_lossy()).into_owned()
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
let host_position = |entry: &SkillCatalogEntry| {
(entry.authority.kind == SkillSourceKind::Host).then(|| {
let path = normalized_path(entry.main_prompt.as_str());
host_paths
.iter()
.position(|host_path| host_path == path.as_ref())
.unwrap_or(host_paths.len())
})
};
match (host_position(left), host_position(right)) {
(Some(left), Some(right)) => left.cmp(&right),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => left
.authority
.kind
.to_string()
.cmp(&right.authority.kind.to_string())
.then_with(|| left.authority.id.cmp(&right.authority.id))
.then_with(|| left.id.0.cmp(&right.id.0)),
}
});
}
let absolute = build_available_skills_from_lines(
entries
.iter()
.map(|entry| SkillLine::from_catalog_entry(entry))
.collect(),
entries.len(),
budget,
SkillPathAliases::default(),
)
.and_then(|available| bound_runtime_skill_metadata_bytes(available, budget));
let should_try_aliases = absolute.as_ref().is_none_or(|absolute| {
absolute.report.omitted_count > 0 || absolute.report.truncated_description_chars > 0
});
let aliased = if should_try_aliases
&& entries
.iter()
.all(|entry| entry.authority.kind == SkillSourceKind::Host)
{
host_outcome
.and_then(|outcome| {
build_aliased_available_skills_from_catalog(outcome, &entries, budget)
})
.and_then(|available| bound_runtime_skill_metadata_bytes(available, budget))
} else {
None
};
let selected = match (absolute, aliased) {
(Some(absolute), Some(aliased))
if aliased_render_is_better(&aliased, &absolute, budget) =>
{
aliased
}
(Some(absolute), _) => absolute,
(None, Some(aliased)) => aliased,
(None, None) => return None,
};
record_available_skills_side_effects(&selected, budget, side_effects);
Some(selected)
}
fn build_available_skills_from_lines(
skill_lines: Vec<SkillLine<'_>>,
total_count: usize,
@@ -213,7 +314,58 @@ fn build_available_skills_from_lines(
}
let (skill_lines, report) = render_skill_lines_from_lines(skill_lines, total_count, budget);
let warning_message = if report.omitted_count > 0 {
let warning_message = skill_metadata_warning(&report, budget);
let available = AvailableSkills {
skill_root_lines: path_aliases.skill_root_lines,
skill_lines,
report,
warning_message,
};
Some(available)
}
fn bound_runtime_skill_metadata_bytes(
mut available: AvailableSkills,
budget: SkillMetadataBudget,
) -> Option<AvailableSkills> {
let mut used_bytes = available
.skill_root_lines
.iter()
.map(|line| line.len().saturating_add(1))
.sum::<usize>();
if used_bytes > MAX_RUNTIME_SKILL_METADATA_BYTES {
return None;
}
available.skill_lines.retain(|line| {
let line_bytes = line.len().saturating_add(1);
let Some(next_bytes) = used_bytes.checked_add(line_bytes) else {
return false;
};
if next_bytes > MAX_RUNTIME_SKILL_METADATA_BYTES {
return false;
}
used_bytes = next_bytes;
true
});
if available.skill_lines.is_empty() {
return None;
}
available.report.included_count = available.skill_lines.len();
available.report.omitted_count = available
.report
.total_count
.saturating_sub(available.report.included_count);
available.warning_message = skill_metadata_warning(&available.report, budget);
Some(available)
}
fn skill_metadata_warning(
report: &SkillRenderReport,
budget: SkillMetadataBudget,
) -> Option<String> {
if report.omitted_count > 0 {
let skill_word = if report.omitted_count == 1 {
"skill"
} else {
@@ -243,14 +395,7 @@ fn build_available_skills_from_lines(
)
} else {
None
};
let available = AvailableSkills {
skill_root_lines: path_aliases.skill_root_lines,
skill_lines,
report,
warning_message,
};
Some(available)
}
}
fn record_available_skills_side_effects(
@@ -450,6 +595,7 @@ impl SkillRenderReport {
struct SkillLine<'a> {
name: &'a str,
description: Cow<'a, str>,
locator: &'static str,
path: String,
}
@@ -492,6 +638,29 @@ impl<'a> SkillLine<'a> {
Self {
name: skill.name.as_str(),
description,
locator: "file",
path,
}
}
fn from_catalog_entry(entry: &'a SkillCatalogEntry) -> Self {
Self::from_catalog_entry_with_path(entry, entry.rendered_path().to_string())
}
fn from_catalog_entry_with_path(entry: &'a SkillCatalogEntry, path: String) -> Self {
let locator = match &entry.authority.kind {
SkillSourceKind::Host => "file",
SkillSourceKind::Executor => "environment resource",
SkillSourceKind::Orchestrator => "orchestrator resource",
};
let description = entry
.short_description
.as_deref()
.unwrap_or(entry.description.as_str());
Self {
name: &entry.name,
description: truncate_default_context_skill_description(description),
locator,
path,
}
}
@@ -525,19 +694,25 @@ impl<'a> SkillLine<'a> {
fn render_with_description_chars(&self, description_chars: usize) -> String {
if description_chars == 0 {
format!("- {}: (file: {})", self.name, self.path)
format!("- {}: ({}: {})", self.name, self.locator, self.path)
} else {
let end = self.rendered_description_prefix_len(description_chars);
let description = &self.description.as_ref()[..end];
format!("- {}: {} (file: {})", self.name, description, self.path)
format!(
"- {}: {} ({}: {})",
self.name, description, self.locator, self.path
)
}
}
fn render_with_description(&self, description: &str) -> String {
if description.is_empty() {
format!("- {}: (file: {})", self.name, self.path)
format!("- {}: ({}: {})", self.name, self.locator, self.path)
} else {
format!("- {}: {} (file: {})", self.name, description, self.path)
format!(
"- {}: {} ({}: {})",
self.name, description, self.locator, self.path
)
}
}
}
@@ -682,6 +857,57 @@ fn build_aliased_available_skills(
build_available_skills_from_lines(skill_lines, skills.len(), adjusted_budget, plan.aliases)
}
fn build_aliased_available_skills_from_catalog(
outcome: &SkillLoadOutcome,
entries: &[&SkillCatalogEntry],
budget: SkillMetadataBudget,
) -> Option<AvailableSkills> {
let host_skills = outcome
.allowed_skills_for_implicit_invocation()
.into_iter()
.filter(|skill| {
entries.iter().any(|entry| {
entry.authority.kind == SkillSourceKind::Host
&& normalized_path(entry.main_prompt.as_str())
== normalized_path(&skill.path_to_skills_md.to_string_lossy())
})
})
.collect::<Vec<_>>();
let plan = build_alias_plan(outcome, &host_skills, budget)?;
if plan.table_cost >= budget.limit() {
return None;
}
let adjusted_limit = budget.limit().saturating_sub(plan.table_cost);
let adjusted_budget = match budget {
SkillMetadataBudget::Tokens(_) => SkillMetadataBudget::Tokens(adjusted_limit),
SkillMetadataBudget::Characters(_) => SkillMetadataBudget::Characters(adjusted_limit),
};
let skill_lines = entries
.iter()
.map(|entry| {
let path = host_skills
.iter()
.find(|skill| {
entry.authority.kind == SkillSourceKind::Host
&& normalized_path(entry.main_prompt.as_str())
== normalized_path(&skill.path_to_skills_md.to_string_lossy())
})
.map(|skill| render_skill_path_with_aliases(skill, &plan))
.unwrap_or_else(|| entry.rendered_path().to_string());
SkillLine::from_catalog_entry_with_path(entry, path)
})
.collect();
build_available_skills_from_lines(skill_lines, entries.len(), adjusted_budget, plan.aliases)
}
fn normalized_path(path: &str) -> Cow<'_, str> {
if path.contains('\\') {
Cow::Owned(path.replace('\\', "/"))
} else {
Cow::Borrowed(path)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct SkillPathAliases {
skill_root_lines: Vec<String>,
@@ -932,6 +1158,9 @@ mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use crate::runtime::SkillAuthority;
use crate::runtime::SkillPackageId;
use crate::runtime::SkillResourceId;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -960,6 +1189,46 @@ mod tests {
skill
}
#[test]
fn catalog_rendering_keeps_a_hard_byte_cap_for_multibyte_metadata() {
let description = "💡".repeat(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + 1);
let entries = ["alpha", "beta"]
.into_iter()
.map(|name| {
SkillCatalogEntry::new(
SkillPackageId(name.to_string()),
SkillAuthority::new(SkillSourceKind::Executor, "executor"),
name,
description.clone(),
SkillResourceId::new(format!("{name}/SKILL.md")),
)
})
.collect();
let catalog = SkillCatalog {
entries,
warnings: Vec::new(),
};
let rendered = build_available_skills_from_catalog(
&catalog,
/*host_outcome*/ None,
SkillMetadataBudget::Characters(usize::MAX),
SkillRenderSideEffects::None,
)
.expect("one catalog entry should fit");
let metadata_bytes = rendered
.skill_root_lines
.iter()
.chain(&rendered.skill_lines)
.map(|line| line.len().saturating_add(1))
.sum::<usize>();
assert!(metadata_bytes <= MAX_RUNTIME_SKILL_METADATA_BYTES);
assert_eq!(rendered.report.included_count, 1);
assert_eq!(rendered.report.omitted_count, 1);
assert!(rendered.warning_message.is_some());
}
fn expected_skill_line(skill: &SkillMetadata, description: &str) -> String {
SkillLine::new(skill).render_with_description(description)
}
+39 -40
View File
@@ -1,4 +1,5 @@
use std::any::Any;
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::hash::Hash;
@@ -20,21 +21,14 @@ pub enum SkillSourceKind {
Executor,
/// Skills owned by the orchestrator rather than an execution environment.
Orchestrator,
/// A source kind for providers that do not fit an existing transport category.
Custom(String),
}
impl SkillSourceKind {
pub fn custom(kind: impl Into<String>) -> Self {
Self::Custom(kind.into())
}
fn as_str(&self) -> &str {
match self {
Self::Host => "host",
Self::Executor => "executor",
Self::Orchestrator => "orchestrator",
Self::Custom(kind) => kind,
}
}
}
@@ -189,13 +183,6 @@ pub struct SkillCatalog {
}
impl SkillCatalog {
pub fn extend(&mut self, other: SkillCatalog) {
for entry in other.entries {
self.push_entry(entry);
}
self.warnings.extend(other.warnings);
}
pub fn push_entry(&mut self, entry: SkillCatalogEntry) {
if self
.entries
@@ -290,9 +277,6 @@ impl Hash for SkillSourceIdentity {
/// A resource returned by [`SkillSource::list`] must be read through the same
/// source instead of being converted into an ambient local path.
pub trait SkillSource: Send + Sync {
/// Returns the authority category owned by this source.
fn kind(&self) -> SkillSourceKind;
/// Returns the stable identity of the runtime owner captured by this source.
fn identity(&self) -> SkillSourceIdentity;
@@ -306,7 +290,7 @@ pub trait SkillSource: Send + Sync {
#[derive(Clone)]
struct RegisteredSkillSource {
label: String,
source: Arc<dyn SkillSource>,
source: Arc<dyn Fn() -> Arc<dyn SkillSource> + Send + Sync>,
}
/// Bound skill sources used to build and read one runtime catalog.
@@ -321,6 +305,18 @@ impl SkillSources {
}
pub fn with_source(mut self, label: impl Into<String>, source: Arc<dyn SkillSource>) -> Self {
self.sources.push(RegisteredSkillSource {
label: label.into(),
source: Arc::new(move || Arc::clone(&source)),
});
self
}
pub fn with_source_factory(
mut self,
label: impl Into<String>,
source: Arc<dyn Fn() -> Arc<dyn SkillSource> + Send + Sync>,
) -> Self {
self.sources.push(RegisteredSkillSource {
label: label.into(),
source,
@@ -328,36 +324,39 @@ impl SkillSources {
self
}
pub async fn list(&self) -> SkillCatalog {
pub fn extend(&mut self, other: Self) {
self.sources.extend(other.sources);
}
pub(crate) async fn list_with_sources(
&self,
) -> (
SkillCatalog,
HashMap<(SkillAuthority, SkillPackageId), Arc<dyn SkillSource>>,
) {
let mut catalog = SkillCatalog::default();
let mut sources = HashMap::new();
for source in &self.sources {
match source.source.list().await {
Ok(source_catalog) => catalog.extend(source_catalog),
let bound_source = (source.source)();
match bound_source.list().await {
Ok(source_catalog) => {
catalog.warnings.extend(source_catalog.warnings);
for entry in source_catalog.entries {
let key = (entry.authority.clone(), entry.id.clone());
if let std::collections::hash_map::Entry::Vacant(route) = sources.entry(key)
{
route.insert(Arc::clone(&bound_source));
catalog.entries.push(entry);
}
}
}
Err(err) => catalog.warnings.push(format!(
"{} skills unavailable: {}",
source.label, err.message
)),
}
}
catalog
}
pub async fn list_kind(&self, kind: &SkillSourceKind) -> SkillSourceResult<SkillCatalog> {
let mut catalog = SkillCatalog::default();
for source in self
.sources
.iter()
.filter(|source| source.source.kind() == *kind)
{
let source_catalog = source.source.list().await.map_err(|err| {
SkillSourceError::new(format!(
"{} skills unavailable: {}",
source.label, err.message
))
})?;
catalog.extend(source_catalog);
}
Ok(catalog)
(catalog, sources)
}
}
@@ -0,0 +1,180 @@
use std::collections::HashSet;
use codex_protocol::user_input::UserInput;
use crate::injection::extract_tool_mentions;
use crate::runtime::SkillAuthority;
use crate::runtime::SkillCatalog;
use crate::runtime::SkillCatalogEntry;
use crate::runtime::SkillPackageId;
use crate::runtime::SkillSourceKind;
const SKILL_PATH_PREFIX: &str = "skill://";
/// Selects explicit skill mentions from one authority-aware runtime catalog.
///
/// Exact locators win. Plain names prefer executor, then orchestrator, then
/// host skills, and must be unique within the first matching authority kind.
/// Plain names that collide with another tool namespace are ignored.
/// A structured selection blocks a plain-name fallback even when its locator
/// no longer exists.
pub fn collect_runtime_skill_mentions(
inputs: &[UserInput],
catalog: &SkillCatalog,
plain_name_conflicts: &HashSet<String>,
) -> 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);
}
_ => {}
}
}
for input in inputs {
let UserInput::Text { text, .. } = input else {
continue;
};
let mentions = extract_tool_mentions(text);
select_text_mentions(
catalog,
&mentions,
&blocked_plain_names,
plain_name_conflicts,
&mut seen,
&mut selected,
);
}
selected
}
fn select_text_mentions(
catalog: &SkillCatalog,
mentions: &crate::injection::ToolMentions<'_>,
blocked_plain_names: &HashSet<String>,
plain_name_conflicts: &HashSet<String>,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let mentioned_paths = mentions
.paths()
.filter(|path| path_is_skill(path))
.map(normalize_skill_path)
.collect::<HashSet<_>>();
for entry in catalog.entries.iter().filter(|entry| entry.enabled) {
if entry_paths(entry)
.into_iter()
.any(|path| mentioned_paths.contains(normalize_skill_path(path)))
{
push_selected(entry, seen, selected);
}
}
let selected_names = mentions
.plain_names()
.filter(|name| !blocked_plain_names.contains(*name))
.filter(|name| !plain_name_conflicts.contains(&name.to_ascii_lowercase()))
.filter_map(|name| select_by_name(catalog, name))
.map(SkillCatalogEntryKey::from)
.collect::<HashSet<_>>();
for entry in &catalog.entries {
if selected_names.contains(&SkillCatalogEntryKey::from(entry)) {
push_selected(entry, seen, selected);
}
}
}
fn select_by_path(
catalog: &SkillCatalog,
path: &str,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
let path = normalize_skill_path(path);
for entry in catalog.entries.iter().filter(|entry| entry.enabled) {
if entry_paths(entry)
.into_iter()
.any(|candidate| normalize_skill_path(candidate) == path)
{
push_selected(entry, seen, selected);
}
}
}
fn entry_paths(entry: &SkillCatalogEntry) -> [&str; 3] {
[
entry.main_prompt.as_str(),
entry.id.0.as_str(),
entry.rendered_path(),
]
}
fn select_by_name<'a>(catalog: &'a SkillCatalog, name: &str) -> Option<&'a SkillCatalogEntry> {
for kind in [
SkillSourceKind::Executor,
SkillSourceKind::Orchestrator,
SkillSourceKind::Host,
] {
let mut matches = catalog
.entries
.iter()
.filter(|entry| entry.enabled && entry.authority.kind == kind && entry.name == name);
let first = matches.next();
if first.is_some() {
return first.filter(|_| matches.next().is_none());
}
}
None
}
fn push_selected(
entry: &SkillCatalogEntry,
seen: &mut HashSet<SkillCatalogEntryKey>,
selected: &mut Vec<SkillCatalogEntry>,
) {
if seen.insert(SkillCatalogEntryKey::from(entry)) {
selected.push(entry.clone());
}
}
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(),
}
}
}
#[cfg(test)]
#[path = "runtime_selection_tests.rs"]
mod tests;
@@ -0,0 +1,66 @@
use std::collections::HashSet;
use codex_protocol::user_input::UserInput;
use pretty_assertions::assert_eq;
use super::*;
fn entry(name: &str, path: &str) -> SkillCatalogEntry {
SkillCatalogEntry::new(
SkillPackageId(path.to_string()),
SkillAuthority::new(SkillSourceKind::Host, "host"),
name,
format!("Use {name}."),
crate::runtime::SkillResourceId::new(path),
)
}
fn text_input(text: &str) -> UserInput {
UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}
}
#[test]
fn text_mentions_follow_catalog_order() {
let beta = entry("beta", "/skills/beta/SKILL.md");
let alpha = entry("alpha", "/skills/alpha/SKILL.md");
let catalog = SkillCatalog {
entries: vec![beta.clone(), alpha.clone()],
warnings: Vec::new(),
};
let selected = collect_runtime_skill_mentions(
&[text_input("Use $alpha and $beta")],
&catalog,
&HashSet::new(),
);
assert_eq!(selected, vec![beta, alpha]);
}
#[test]
fn connector_collision_blocks_plain_name_but_not_exact_locator() {
let calendar = entry("calendar", "skill://executor/calendar/SKILL.md");
let catalog = SkillCatalog {
entries: vec![calendar.clone()],
warnings: Vec::new(),
};
let conflicts = HashSet::from(["calendar".to_string()]);
assert_eq!(
collect_runtime_skill_mentions(&[text_input("Use $calendar")], &catalog, &conflicts),
Vec::new()
);
assert_eq!(
collect_runtime_skill_mentions(
&[text_input(
"Use [$calendar](skill://executor/calendar/SKILL.md)",
)],
&catalog,
&conflicts,
),
vec![calendar]
);
}
+29 -1
View File
@@ -1,8 +1,13 @@
use codex_context_fragments::ContextualUserFragment;
use crate::injection::SkillInjection;
use crate::runtime::SkillCatalogEntry;
#[derive(Debug, Clone, PartialEq)]
const MAX_SKILL_NAME_BYTES: usize = 256;
const MAX_SKILL_PATH_BYTES: usize = 1_024;
const MAX_SKILL_BODY_BYTES: usize = 8_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillInstructions {
name: String,
path: String,
@@ -19,6 +24,29 @@ impl From<&SkillInjection> for SkillInstructions {
}
}
impl SkillInstructions {
/// Builds one hard-bounded instruction fragment from a runtime catalog entry.
pub fn from_runtime(entry: &SkillCatalogEntry, contents: &str) -> (Self, bool) {
let (contents, truncated) = truncate_utf8(contents, MAX_SKILL_BODY_BYTES);
(
Self {
name: truncate_utf8(&entry.name, MAX_SKILL_NAME_BYTES).0,
path: truncate_utf8(entry.rendered_path(), MAX_SKILL_PATH_BYTES).0,
contents,
},
truncated,
)
}
}
fn truncate_utf8(value: &str, max_bytes: usize) -> (String, bool) {
let mut end = value.len().min(max_bytes);
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
(value[..end].to_string(), end < value.len())
}
impl ContextualUserFragment for SkillInstructions {
fn role(&self) -> &'static str {
"user"
-23
View File
@@ -1,23 +0,0 @@
pub use codex_core_skills::runtime::SkillAuthority;
pub use codex_core_skills::runtime::SkillCatalog;
pub use codex_core_skills::runtime::SkillCatalogEntry;
pub use codex_core_skills::runtime::SkillPackageId;
pub use codex_core_skills::runtime::SkillReadResult;
pub use codex_core_skills::runtime::SkillResourceId;
pub use codex_core_skills::runtime::SkillSourceError as SkillProviderError;
pub use codex_core_skills::runtime::SkillSourceKind;
pub use codex_core_skills::runtime::SkillSourceResult as SkillProviderResult;
/// Search results for a package whose files are not readable through ordinary
/// executor filesystem access.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SkillSearchResult {
pub matches: Vec<SkillSearchMatch>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillSearchMatch {
pub resource: SkillResourceId,
pub title: String,
pub snippet: String,
}
-10
View File
@@ -1,10 +0,0 @@
/// Host-supplied configuration used by the skills extension.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SkillsExtensionConfig {
/// Whether the available-skills catalog is included in model context.
pub include_instructions: bool,
/// Whether bundled skills are eligible for discovery.
pub bundled_skills_enabled: bool,
/// Whether orchestrator-owned skills are eligible for discovery.
pub orchestrator_skills_enabled: bool,
}
+25 -294
View File
@@ -1,53 +1,23 @@
use std::sync::Arc;
use codex_core_skills::HostSkillsSnapshot;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputContributor;
use codex_mcp::McpResourceClient;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use crate::SkillsExtensionConfig;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSourceKind;
use crate::fragments::SkillInstructions;
use crate::provider::HostSkillProvider;
use crate::provider::SkillListQuery;
use crate::provider::SkillReadRequest;
use crate::render::MAX_SKILL_NAME_BYTES;
use crate::render::MAX_SKILL_PATH_BYTES;
use crate::render::available_skills_fragment;
use crate::render::truncate_main_prompt_contents;
use crate::render::truncate_utf8_to_bytes;
use crate::selection::collect_explicit_skill_mentions;
use crate::sources::SkillProviders;
use crate::sources::orchestrator_skill_sources;
use crate::state::SkillsThreadState;
use crate::state::SkillsTurnState;
use crate::tools::skill_tools;
struct SkillsExtension<C> {
providers: SkillProviders,
event_sink: Arc<dyn ExtensionEventSink>,
config_from_host: Arc<dyn Fn(&C) -> SkillsExtensionConfig + Send + Sync>,
orchestrator_skills_enabled: Arc<dyn Fn(&C) -> bool + Send + Sync>,
}
impl<C> ThreadLifecycleContributor<C> for SkillsExtension<C>
@@ -56,19 +26,21 @@ where
{
fn on_thread_start<'a>(&'a self, input: ThreadStartInput<'a, C>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let selected_roots = input
.thread_store
.get::<Vec<SelectedCapabilityRoot>>()
.map(|selected_roots| selected_roots.as_ref().clone())
.unwrap_or_default();
let orchestrator_skills_available = !input
.environments
.iter()
.any(|environment| environment.environment_id == LOCAL_ENVIRONMENT_ID);
input.thread_store.insert(SkillsThreadState::new(
(self.config_from_host)(input.config),
selected_roots,
orchestrator_skills_available,
let thread_state = input.thread_store.get_or_init(|| {
SkillsThreadState::new(
(self.orchestrator_skills_enabled)(input.config),
orchestrator_skills_available,
)
});
thread_state
.set_orchestrator_skills_enabled((self.orchestrator_skills_enabled)(input.config));
input.thread_store.insert(orchestrator_skill_sources(
thread_state,
input.session_store.get::<McpResourceClient>(),
));
})
}
@@ -80,67 +52,26 @@ where
{
fn on_config_changed(
&self,
_session_store: &ExtensionData,
session_store: &ExtensionData,
thread_store: &ExtensionData,
_previous_config: &C,
new_config: &C,
) {
let next_config = (self.config_from_host)(new_config);
let enabled = (self.orchestrator_skills_enabled)(new_config);
if let Some(state) = thread_store.get::<SkillsThreadState>() {
state.set_config(next_config);
state.set_orchestrator_skills_enabled(enabled);
} else {
let orchestrator_skills_available = true;
thread_store.insert(SkillsThreadState::new(
next_config,
Vec::new(),
orchestrator_skills_available,
let thread_state = thread_store
.get_or_init(|| SkillsThreadState::new(enabled, orchestrator_skills_available));
thread_store.insert(orchestrator_skill_sources(
thread_state,
session_store.get::<McpResourceClient>(),
));
}
}
}
impl<C> ContextContributor for SkillsExtension<C>
where
C: Send + Sync + 'static,
{
fn contribute_thread_context<'a>(
&'a self,
session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(async move {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let config = thread_state.config();
if !config.include_instructions {
return Vec::new();
}
let catalog = self
.list_skills(
SkillListQuery {
turn_id: thread_store.level_id().to_string(),
executor_roots: thread_state.selected_roots().to_vec(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: config.bundled_skills_enabled,
include_orchestrator_skills: thread_state.orchestrator_skills_enabled(),
mcp_resources: session_store.get::<McpResourceClient>(),
},
&thread_state,
)
.await;
for warning in &catalog.warnings {
self.emit_warning(thread_store.level_id(), warning.clone());
}
available_skills_fragment(&catalog)
.map(|fragment| PromptFragment::developer_capability(fragment.render()))
.into_iter()
.collect()
})
}
}
impl<C> ToolContributor for SkillsExtension<C>
where
C: Send + Sync + 'static,
@@ -153,224 +84,24 @@ where
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
if !self.providers.has_orchestrator_provider()
|| !thread_state.orchestrator_skills_enabled()
{
if !thread_state.orchestrator_skills_enabled() {
return Vec::new();
}
skill_tools(
self.providers.clone(),
session_store.get::<McpResourceClient>(),
thread_state,
)
}
}
impl<C> TurnInputContributor for SkillsExtension<C>
where
C: Send + Sync + 'static,
{
fn contribute<'a>(
&'a self,
input: TurnInputContext,
session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
) -> ExtensionFuture<'a, Vec<Box<dyn ContextualUserFragment + Send>>> {
Box::pin(async move {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let config = thread_state.config();
let host_snapshot = turn_store.get::<HostSkillsSnapshot>();
let query = SkillListQuery {
turn_id: input.turn_id.clone(),
executor_roots: thread_state.selected_roots().to_vec(),
host_snapshot: host_snapshot.clone(),
include_host_skills: true,
include_bundled_skills: config.bundled_skills_enabled,
include_orchestrator_skills: thread_state.orchestrator_skills_enabled(),
mcp_resources: session_store.get::<McpResourceClient>(),
};
let catalog = self.list_skills(query, &thread_state).await;
for warning in &catalog.warnings {
self.emit_warning(&input.turn_id, warning.clone());
}
let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog);
let mut fragments: Vec<Box<dyn ContextualUserFragment + Send>> = Vec::new();
if config.include_instructions {
let mut turn_catalog = catalog.clone();
turn_catalog.entries.retain(|entry| {
entry.authority.kind != SkillSourceKind::Executor
&& entry.authority.kind != SkillSourceKind::Orchestrator
});
if let Some(fragment) = available_skills_fragment(&turn_catalog) {
fragments.push(Box::new(fragment));
}
}
let mut warnings = catalog.warnings.clone();
let mut main_prompts_injected = false;
let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default();
for entry in &selected_entries {
match self
.read_main_prompt(entry, host_snapshot.clone(), session_store, &thread_state)
.await
{
Ok(read_result) => {
let (contents, truncated) =
truncate_main_prompt_contents(read_result.contents.as_str());
if truncated {
let warning = format!(
"Skill `{}` exceeded the main prompt context limit and was truncated.",
entry.name
);
self.emit_warning(&input.turn_id, warning.clone());
warnings.push(warning);
}
let fragment = SkillInstructions {
name: truncate_utf8_to_bytes(&entry.name, MAX_SKILL_NAME_BYTES).0,
path: truncate_utf8_to_bytes(
entry.rendered_path(),
MAX_SKILL_PATH_BYTES,
)
.0,
contents,
};
fragments.push(Box::new(fragment));
main_prompts_injected = true;
if entry.authority.kind == SkillSourceKind::Host {
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
}
}
Err(message) => {
let warning = format!("Failed to load skill `{}`: {message}", entry.name);
self.emit_warning(&input.turn_id, warning.clone());
warnings.push(warning);
}
}
}
if let Some(host_snapshot) = &host_snapshot {
for entry in selected_entries
.iter()
.filter(|entry| entry.authority.kind != SkillSourceKind::Host)
{
for host_skill in host_snapshot
.outcome()
.skills
.iter()
.filter(|host_skill| host_skill.name == entry.name)
{
injected_host_skill_prompts
.insert_path(host_skill.path_to_skills_md.to_string_lossy());
}
}
}
turn_store.insert(SkillsTurnState {
catalog,
selected_entries,
warnings,
main_prompts_injected,
});
if !injected_host_skill_prompts.is_empty() {
turn_store.insert(injected_host_skill_prompts);
}
fragments
})
}
}
impl<C> SkillsExtension<C> {
#[tracing::instrument(level = "trace", skip_all)]
async fn list_skills(
&self,
mut query: SkillListQuery,
thread_state: &SkillsThreadState,
) -> SkillCatalog {
let include_orchestrator_skills = query.include_orchestrator_skills;
let orchestrator_query = query.clone();
let mcp_resources = orchestrator_query.mcp_resources.clone();
query.include_orchestrator_skills = false;
let mut catalog = self.providers.list_for_turn(query).await;
if include_orchestrator_skills {
let orchestrator_catalog = thread_state
.orchestrator_catalog_snapshot(
mcp_resources.as_deref(),
self.providers
.list_orchestrator_for_turn(orchestrator_query),
)
.await;
catalog.extend(orchestrator_catalog);
}
catalog
}
#[tracing::instrument(level = "trace", skip_all, fields(skill = %entry.name))]
async fn read_main_prompt(
&self,
entry: &SkillCatalogEntry,
host_snapshot: Option<Arc<HostSkillsSnapshot>>,
session_store: &ExtensionData,
thread_state: &SkillsThreadState,
) -> Result<SkillReadResult, String> {
thread_state
.read_skill(
&self.providers,
SkillReadRequest {
authority: entry.authority.clone(),
package: entry.id.clone(),
resource: entry.main_prompt.clone(),
host_snapshot,
mcp_resources: session_store.get::<McpResourceClient>(),
},
)
.await
.map_err(|err| err.message)
}
fn emit_warning(&self, turn_id: &str, message: String) {
self.event_sink.emit(Event {
id: turn_id.to_string(),
msg: EventMsg::Warning(WarningEvent { message }),
});
skill_tools(session_store.get::<McpResourceClient>(), thread_state)
}
}
pub fn install<C>(
registry: &mut ExtensionRegistryBuilder<C>,
config_from_host: impl Fn(&C) -> SkillsExtensionConfig + Send + Sync + 'static,
) where
C: Send + Sync + 'static,
{
install_with_providers(
registry,
SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())),
config_from_host,
);
}
pub fn install_with_providers<C>(
registry: &mut ExtensionRegistryBuilder<C>,
providers: SkillProviders,
config_from_host: impl Fn(&C) -> SkillsExtensionConfig + Send + Sync + 'static,
orchestrator_skills_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
) where
C: Send + Sync + 'static,
{
let extension = Arc::new(SkillsExtension {
providers,
event_sink: registry.event_sink(),
config_from_host: Arc::new(config_from_host),
orchestrator_skills_enabled: Arc::new(orchestrator_skills_enabled),
});
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone());
registry.turn_input_contributor(extension.clone());
registry.tool_contributor(extension);
}
+9 -32
View File
@@ -1,3 +1,4 @@
use codex_core_skills::AvailableSkills;
use codex_core_skills::render_available_skills_body;
use codex_extension_api::ContextualUserFragment;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
@@ -5,12 +6,16 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AvailableSkillsInstructions {
skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
}
impl AvailableSkillsInstructions {
pub(crate) fn from_skill_lines(skill_lines: Vec<String>) -> Self {
Self { skill_lines }
impl From<AvailableSkills> for AvailableSkillsInstructions {
fn from(available: AvailableSkills) -> Self {
Self {
skill_root_lines: available.skill_root_lines,
skill_lines: available.skill_lines,
}
}
}
@@ -28,34 +33,6 @@ impl ContextualUserFragment for AvailableSkillsInstructions {
}
fn body(&self) -> String {
render_available_skills_body(&[], &self.skill_lines)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SkillInstructions {
pub(crate) name: String,
pub(crate) path: String,
pub(crate) contents: String,
}
impl ContextualUserFragment for SkillInstructions {
fn role(&self) -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<skill>", "</skill>")
}
fn body(&self) -> String {
let name = &self.name;
let path = &self.path;
let contents = &self.contents;
format!("\n<name>{name}</name>\n<path>{path}</path>\n{contents}\n")
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
}
}
-13
View File
@@ -1,20 +1,7 @@
pub mod catalog;
mod config;
mod extension;
mod fragments;
pub mod provider;
mod render;
mod selection;
mod sources;
mod state;
mod tools;
pub use config::SkillsExtensionConfig;
pub use extension::install;
pub use extension::install_with_providers;
pub use provider::ExecutorSkillProvider;
pub use provider::HostSkillProvider;
pub use provider::OrchestratorSkillProvider;
pub use provider::SkillProvider;
pub use sources::SkillProviderSource;
pub use sources::SkillProviders;
-66
View File
@@ -1,66 +0,0 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
mod executor;
mod host;
mod orchestrator;
use codex_core_skills::HostSkillsSnapshot;
use codex_mcp::McpResourceClient;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
pub use executor::ExecutorSkillProvider;
pub use host::HostSkillProvider;
pub use orchestrator::OrchestratorSkillProvider;
#[derive(Clone, Debug)]
pub struct SkillListQuery {
pub turn_id: String,
pub executor_roots: Vec<SelectedCapabilityRoot>,
pub host_snapshot: Option<Arc<HostSkillsSnapshot>>,
pub include_host_skills: bool,
pub include_bundled_skills: bool,
pub include_orchestrator_skills: bool,
pub mcp_resources: Option<Arc<McpResourceClient>>,
}
#[derive(Clone, Debug)]
pub struct SkillReadRequest {
pub authority: SkillAuthority,
pub package: SkillPackageId,
pub resource: SkillResourceId,
pub host_snapshot: Option<Arc<HostSkillsSnapshot>>,
pub mcp_resources: Option<Arc<McpResourceClient>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillSearchRequest {
pub authority: SkillAuthority,
pub package: SkillPackageId,
pub query: String,
}
pub type SkillProviderFuture<'a, T> =
Pin<Box<dyn Future<Output = SkillProviderResult<T>> + Send + 'a>>;
/// Source-specific skill catalog and resource access.
///
/// Implementations must preserve authority boundaries: a resource listed by a
/// provider must be read or searched through the same provider/authority rather
/// than converted into an ambient local path.
pub trait SkillProvider: Send + Sync {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog>;
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult>;
fn search(&self, request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult>;
}
@@ -1,166 +0,0 @@
use std::sync::Arc;
use codex_core_skills::loader::EnvironmentSkillMetadata;
use codex_core_skills::loader::load_environment_skills_from_root;
use codex_exec_server::EnvironmentManager;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::protocol::Product;
use codex_utils_path_uri::PathConvention;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
/// Discovers and reads skills through the filesystem owned by an execution environment.
#[derive(Clone, Debug)]
pub struct ExecutorSkillProvider {
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
}
impl ExecutorSkillProvider {
pub fn new_with_restriction_product(
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
) -> Self {
Self {
environment_manager,
restriction_product,
}
}
}
impl SkillProvider for ExecutorSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
Box::pin(async move {
let mut catalog = SkillCatalog::default();
for selected_root in query.executor_roots {
let selected_root_id = selected_root.id;
let CapabilityRootLocation::Environment {
environment_id,
path,
} = selected_root.location;
let authority =
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
let Some(environment) = self.environment_manager.get_environment(&environment_id)
else {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
));
continue;
};
let file_system = environment.get_filesystem();
let outcome = load_environment_skills_from_root(
file_system.as_ref(),
&path,
self.restriction_product,
)
.await;
catalog.warnings.extend(outcome.warnings);
for skill in outcome.skills {
catalog.push_entry(catalog_entry_from_skill(
&skill,
authority.clone(),
&selected_root_id,
&environment_id,
));
}
}
Ok(catalog)
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
Box::pin(async move {
if request.authority.kind != SkillSourceKind::Executor {
return Err(SkillProviderError::new(format!(
"executor skill provider cannot read {} resources",
request.authority.kind
)));
}
if request.package.0 != request.resource.as_str() {
return Err(SkillProviderError::new(
"executor skill resource does not match its package",
));
}
let Some((environment_id, resource_path)) = request.resource.environment_path() else {
return Err(SkillProviderError::new(
"executor skill resource is not bound to an environment",
));
};
let Some(environment) = self.environment_manager.get_environment(environment_id) else {
return Err(SkillProviderError::new(format!(
"executor skill resource references unavailable environment `{environment_id}`"
)));
};
let contents = environment
.get_filesystem()
.read_file_text(resource_path, /*sandbox*/ None)
.await
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read executor skill resource {}: {err}",
request.resource.as_str()
))
})?;
Ok(SkillReadResult {
resource: request.resource,
contents,
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn catalog_entry_from_skill(
skill: &EnvironmentSkillMetadata,
authority: SkillAuthority,
selected_root_id: &str,
environment_id: &str,
) -> SkillCatalogEntry {
let skill_path = skill.path_to_skills_md.inferred_native_path_string();
let normalized_path = match skill.path_to_skills_md.infer_path_convention() {
Some(PathConvention::Windows) => skill_path.replace('\\', "/"),
Some(PathConvention::Posix) | None => skill_path,
};
let display_path = format!(
"skill://{selected_root_id}/{}",
normalized_path.trim_start_matches('/')
);
let entry = SkillCatalogEntry::new(
SkillPackageId(display_path.clone()),
authority,
skill.name.clone(),
skill.description.clone(),
SkillResourceId::environment(
display_path.clone(),
environment_id,
skill.path_to_skills_md.clone(),
),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_dependencies(skill.dependencies.clone());
if skill.allows_implicit_invocation() {
entry
} else {
entry.hidden_from_prompt()
}
}
-129
View File
@@ -1,129 +0,0 @@
use codex_core_skills::SkillLoadOutcome;
use codex_core_skills::SkillMetadata;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
const HOST_AUTHORITY_ID: &str = "host";
/// Host-owned skill provider backed by an immutable service snapshot.
///
/// Discovery and caching belong to `SkillsService`; this provider only maps a
/// snapshot into the authority-aware catalog/read contract.
#[derive(Clone, Default)]
pub struct HostSkillProvider;
impl HostSkillProvider {
pub fn new() -> Self {
Self
}
}
impl SkillProvider for HostSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
Box::pin(async move {
let Some(host_snapshot) = query.host_snapshot else {
return Err(SkillProviderError::new(
"host skill provider requires a host skills snapshot",
));
};
Ok(catalog_from_outcome(host_snapshot.outcome()))
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
Box::pin(async move {
let Some(host_snapshot) = request.host_snapshot else {
return Err(SkillProviderError::new(
"host skill provider requires a host skills snapshot",
));
};
let Some(skill) = host_snapshot.outcome().skills.iter().find(|skill| {
let skill_path = skill.path_to_skills_md.to_string_lossy();
skill_path == request.resource.as_str()
|| skill_path.replace('\\', "/") == request.resource.as_str()
}) else {
return Err(SkillProviderError::new(format!(
"host skill resource is not loaded: {}",
request.resource.as_str()
)));
};
let contents = host_snapshot.read_skill_text(skill).await.map_err(|err| {
SkillProviderError::new(format!(
"failed to read host skill resource {}: {err}",
request.resource.as_str()
))
})?;
Ok(SkillReadResult {
resource: request.resource,
contents,
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog {
let mut catalog = SkillCatalog {
entries: Vec::new(),
warnings: outcome
.errors
.iter()
.map(|err| {
format!(
"Failed to load skill at {}: {}",
err.path.display(),
err.message
)
})
.collect(),
};
for (skill, enabled) in outcome.skills_with_enabled() {
catalog.push_entry(catalog_entry_from_skill(skill, enabled));
}
catalog
}
fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalogEntry {
let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned();
let display_path = skill_path.replace('\\', "/");
let mut entry = SkillCatalogEntry::new(
SkillPackageId(skill_path.clone()),
SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID),
skill.name.clone(),
skill.description.clone(),
SkillResourceId::new(skill_path),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_dependencies(skill.dependencies.clone());
if !enabled {
entry = entry.disabled();
}
if !skill.allows_implicit_invocation() {
entry = entry.hidden_from_prompt();
}
entry
}
@@ -1,331 +0,0 @@
use std::collections::HashSet;
use std::time::Duration;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceContent;
use url::Url;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
const ORCHESTRATOR_SKILL_MIME_TYPE: &str = "mcp/skill";
const ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
const ORCHESTRATOR_SKILL_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESOURCE_PAGES: usize = 10;
const MAX_ORCHESTRATOR_SKILLS: usize = 100;
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024;
const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048;
const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
/// Discovers and reads skills owned by the orchestrator.
///
/// The provider uses session-scoped resources without exposing the transport or
/// resource server to callers that configure the skills extension.
#[derive(Clone, Debug, Default)]
pub struct OrchestratorSkillProvider;
impl OrchestratorSkillProvider {
pub fn new() -> Self {
Self
}
}
impl SkillProvider for OrchestratorSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
Box::pin(async move {
let Some(client) = query.mcp_resources else {
return Ok(SkillCatalog::default());
};
if !client.has_server(CODEX_APPS_MCP_SERVER_NAME).await {
return Ok(SkillCatalog::default());
}
let discovery_deadline =
tokio::time::Instant::now() + ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT;
let mut catalog = SkillCatalog::default();
let mut cursor = None;
let mut seen_cursors = HashSet::new();
let mut skill_resources_seen = 0usize;
let mut skipped_resources = 0usize;
let mut truncated = false;
let mut completed_pages = 0usize;
for _ in 0..MAX_RESOURCE_PAGES {
let page = match tokio::time::timeout_at(
discovery_deadline,
client.list_resources(CODEX_APPS_MCP_SERVER_NAME, cursor.clone()),
)
.await
{
Ok(result) => result.map_err(|err| {
SkillProviderError::new(format!(
"failed to list orchestrator skill resources: {err:#}"
))
}),
Err(_) => Err(SkillProviderError::new(format!(
"orchestrator skill discovery timed out after {ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT:?}"
))),
};
let result = match page {
Ok(result) => result,
Err(err) if completed_pages == 0 => return Err(err),
Err(err) => {
let page_word = if completed_pages == 1 {
"page"
} else {
"pages"
};
catalog.warnings.push(format!(
"Orchestrator skill discovery stopped after {completed_pages} resource {page_word}: {}",
err.message
));
cursor = None;
break;
}
};
completed_pages = completed_pages.saturating_add(1);
for resource in &result.resources {
if resource.mime_type.as_deref() != Some(ORCHESTRATOR_SKILL_MIME_TYPE) {
continue;
}
if skill_resources_seen >= MAX_ORCHESTRATOR_SKILLS {
truncated = true;
break;
}
skill_resources_seen = skill_resources_seen.saturating_add(1);
match catalog_entry_from_resource(resource) {
Some(entry) => catalog.push_entry(entry),
None => skipped_resources = skipped_resources.saturating_add(1),
}
}
if truncated {
break;
}
let Some(next_cursor) = result.next_cursor else {
cursor = None;
break;
};
if !seen_cursors.insert(next_cursor.clone()) {
catalog.warnings.push(
"Orchestrator skill resource pagination returned a duplicate cursor."
.to_string(),
);
cursor = None;
break;
}
cursor = Some(next_cursor);
}
if cursor.is_some() || truncated {
catalog.warnings.push(format!(
"Orchestrator skill discovery was truncated at {MAX_ORCHESTRATOR_SKILLS} skills or {MAX_RESOURCE_PAGES} resource pages."
));
}
if skipped_resources > 0 {
catalog.warnings.push(format!(
"Skipped {skipped_resources} malformed orchestrator skill resources."
));
}
Ok(catalog)
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
Box::pin(async move {
if request.authority
!= SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
{
return Err(SkillProviderError::new(format!(
"orchestrator skill provider cannot read authority {}",
request.authority.id
)));
}
if !resource_belongs_to_package(&request.package.0, request.resource.as_str()) {
return Err(SkillProviderError::new(
"orchestrator skill resource does not match its package",
));
}
let Some(client) = request.mcp_resources.as_ref() else {
return Err(SkillProviderError::new(
"session MCP resource client is not configured",
));
};
let result = tokio::time::timeout(
ORCHESTRATOR_SKILL_READ_TIMEOUT,
client.read_resource(CODEX_APPS_MCP_SERVER_NAME, request.resource.as_str()),
)
.await
.map_err(|_| {
SkillProviderError::new(format!(
"orchestrator skill read timed out after {ORCHESTRATOR_SKILL_READ_TIMEOUT:?}"
))
})?
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read orchestrator skill resource {}: {err:#}",
request.resource.as_str()
))
})?;
let contents = result
.contents
.into_iter()
.find_map(|contents| match contents {
ResourceContent::Text { uri, text, .. } if uri == request.resource.as_str() => {
Some(text)
}
ResourceContent::Text { .. } | ResourceContent::Blob { .. } => None,
});
let Some(contents) = contents else {
return Err(SkillProviderError::new(format!(
"orchestrator skill resource {} did not return matching text contents",
request.resource.as_str()
)));
};
if contents.len() > MAX_SKILL_RESOURCE_CONTENT_BYTES {
return Err(SkillProviderError::new(format!(
"orchestrator skill resource {} exceeds the {MAX_SKILL_RESOURCE_CONTENT_BYTES}-byte read limit",
request.resource.as_str()
)));
}
Ok(SkillReadResult {
resource: request.resource,
contents,
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry> {
let uri = validated_skill_uri(resource.uri.as_str(), MAX_SKILL_PACKAGE_URI_CHARS)?;
let meta = resource.meta.as_ref()?.as_object()?;
let skill_name = normalized_label(meta.get("skill_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let name = if meta.get("source").and_then(|value| value.as_str()) == Some("user") {
skill_name
} else {
let plugin_name =
normalized_label(meta.get("plugin_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let qualified_name = format!("{plugin_name}:{skill_name}");
(qualified_name.chars().count() <= MAX_QUALIFIED_SKILL_NAME_CHARS)
.then_some(qualified_name)?
};
let description = normalized_description(resource.description.as_deref().unwrap_or_default())?;
let main_prompt = main_prompt_uri(uri);
Some(
SkillCatalogEntry::new(
SkillPackageId(uri.to_string()),
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME),
name,
description,
SkillResourceId::new(main_prompt),
)
.with_display_path(uri),
)
}
fn validated_skill_uri(uri: &str, max_chars: usize) -> Option<&str> {
validated_skill_url(uri, max_chars).map(|_| uri)
}
fn validated_skill_url(uri: &str, max_chars: usize) -> Option<Url> {
if uri.chars().count() > max_chars
|| uri
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '<' | '>'))
{
return None;
}
let url = Url::parse(uri).ok()?;
let path_is_valid = url.path_segments().is_some_and(|segments| {
let segments = segments.collect::<Vec<_>>();
!segments.is_empty() && segments.iter().all(|segment| !segment.is_empty())
});
(url.scheme() == "skill"
&& url.as_str() == uri
&& url.host_str().is_some_and(|host| !host.is_empty())
&& url.username().is_empty()
&& url.password().is_none()
&& url.port().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
&& path_is_valid)
.then_some(url)
}
fn resource_belongs_to_package(package: &str, resource: &str) -> bool {
let Some(package) = validated_skill_url(package, MAX_SKILL_PACKAGE_URI_CHARS) else {
return false;
};
let Some(resource) = validated_skill_url(resource, MAX_SKILL_RESOURCE_URI_CHARS) else {
return false;
};
let Some(package_segments) = package.path_segments() else {
return false;
};
let Some(resource_segments) = resource.path_segments() else {
return false;
};
let package_segments = package_segments.collect::<Vec<_>>();
let resource_segments = resource_segments.collect::<Vec<_>>();
package.scheme() == resource.scheme()
&& package.host_str() == resource.host_str()
&& resource_segments.len() > package_segments.len()
&& resource_segments.starts_with(&package_segments)
}
fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
let value = normalized_single_line(value, max_chars)?;
let invalid = value.is_empty() || value.chars().any(|ch| matches!(ch, '&' | '<' | '>'));
(!invalid).then_some(value)
}
fn normalized_description(value: &str) -> Option<String> {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
if value.chars().any(char::is_control) {
return None;
}
Some(
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;"),
)
}
fn normalized_single_line(value: &str, max_chars: usize) -> Option<String> {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
let valid = value.chars().count() <= max_chars && !value.chars().any(char::is_control);
valid.then_some(value)
}
fn main_prompt_uri(package_uri: &str) -> String {
format!("{}/SKILL.md", package_uri.trim_end_matches('/'))
}
+12 -62
View File
@@ -1,18 +1,15 @@
use std::borrow::Cow;
use codex_core_skills::SkillRenderSideEffects;
use codex_core_skills::build_available_skills_from_catalog;
use codex_core_skills::default_skill_metadata_budget;
use codex_utils_string::take_bytes_at_char_boundary;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillSourceKind;
use crate::fragments::AvailableSkillsInstructions;
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024;
const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "...";
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
#[tracing::instrument(
level = "trace",
@@ -21,42 +18,15 @@ pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
)]
pub(crate) fn available_skills_fragment(
catalog: &SkillCatalog,
) -> Option<AvailableSkillsInstructions> {
let mut total_bytes = 0usize;
let mut omitted = 0usize;
let mut skill_lines = Vec::new();
for entry in catalog
.entries
.iter()
.filter(|entry| entry.enabled && entry.prompt_visible)
{
let description = entry
.short_description
.as_deref()
.unwrap_or(entry.description.as_str());
let description = truncate_catalog_skill_description(description);
let line = render_skill_line(entry, description.as_ref());
let next_bytes = total_bytes.saturating_add(line.len());
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
omitted = omitted.saturating_add(1);
continue;
}
total_bytes = next_bytes;
skill_lines.push(line);
}
if skill_lines.is_empty() {
return None;
}
if omitted > 0 {
let skill_word = if omitted == 1 { "skill" } else { "skills" };
skill_lines.push(format!(
"- {omitted} additional {skill_word} omitted from this bounded skills list."
));
}
Some(AvailableSkillsInstructions::from_skill_lines(skill_lines))
) -> Option<(AvailableSkillsInstructions, Option<String>)> {
let available = build_available_skills_from_catalog(
catalog,
/*host_outcome*/ None,
default_skill_metadata_budget(/*context_window*/ None),
SkillRenderSideEffects::None,
)?;
let warning = available.warning_message.clone();
Some((available.into(), warning))
}
pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> {
@@ -79,26 +49,6 @@ pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, s
Cow::Owned(truncated)
}
fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String {
let locator_kind = match &entry.authority.kind {
SkillSourceKind::Host => "file",
SkillSourceKind::Executor => "environment resource",
SkillSourceKind::Orchestrator => "orchestrator resource",
SkillSourceKind::Custom(_) => "custom resource",
};
let name = entry.name.as_str();
let path = entry.rendered_path();
if description.is_empty() {
format!("- {name}: ({locator_kind}: {path})")
} else {
format!("- {name}: {description} ({locator_kind}: {path})")
}
}
pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) {
truncate_utf8_to_bytes(contents, MAX_MAIN_PROMPT_BYTES)
}
pub(crate) fn truncate_utf8_to_bytes(contents: &str, max_bytes: usize) -> (String, bool) {
let truncated = take_bytes_at_char_boundary(contents, max_bytes);
(truncated.to_string(), truncated.len() < contents.len())
-137
View File
@@ -1,137 +0,0 @@
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://";
#[tracing::instrument(
level = "trace",
skip_all,
fields(
input_count = inputs.len(),
catalog_entry_count = catalog.entries.len()
)
)]
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.as_str() == path
|| entry.id.0 == path
|| entry
.display_path
.as_deref()
.is_some_and(|display_path| normalize_skill_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(),
}
}
}
+361 -216
View File
@@ -1,240 +1,385 @@
use std::fmt;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use codex_core_skills::runtime::SkillReadRequest as RuntimeSkillReadRequest;
use codex_core_skills::runtime::SkillAuthority;
use codex_core_skills::runtime::SkillCatalog;
use codex_core_skills::runtime::SkillCatalogEntry;
use codex_core_skills::runtime::SkillPackageId;
use codex_core_skills::runtime::SkillReadRequest;
use codex_core_skills::runtime::SkillReadResult;
use codex_core_skills::runtime::SkillResourceId;
use codex_core_skills::runtime::SkillSource;
use codex_core_skills::runtime::SkillSourceError;
use codex_core_skills::runtime::SkillSourceFuture;
use codex_core_skills::runtime::SkillSourceIdentity;
use codex_core_skills::runtime::SkillSourceKind;
use codex_core_skills::runtime::SkillSourceResult;
use codex_core_skills::runtime::SkillSources;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpResourceClient;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceContent;
use url::Url;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
use crate::state::SkillsThreadState;
#[derive(Clone)]
pub struct SkillProviderSource {
kind: SkillSourceKind,
label: String,
provider: Arc<dyn SkillProvider>,
identity: SkillSourceIdentity,
}
const ORCHESTRATOR_SKILL_MIME_TYPE: &str = "mcp/skill";
const ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
const ORCHESTRATOR_SKILL_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESOURCE_PAGES: usize = 10;
const MAX_ORCHESTRATOR_SKILLS: usize = 100;
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024;
const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048;
const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
impl SkillProviderSource {
pub fn new(
kind: SkillSourceKind,
label: impl Into<String>,
provider: Arc<dyn SkillProvider>,
) -> Self {
let identity = SkillSourceIdentity::from_owner(Arc::new(Arc::clone(&provider)));
Self {
kind,
label: label.into(),
provider,
identity,
}
}
pub fn host(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
Self::new(SkillSourceKind::Host, label, provider)
}
pub fn executor(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
Self::new(SkillSourceKind::Executor, label, provider)
}
pub fn orchestrator(label: impl Into<String>, provider: Arc<dyn SkillProvider>) -> Self {
Self::new(SkillSourceKind::Orchestrator, label, provider)
}
fn should_list(&self, query: &SkillListQuery) -> bool {
match &self.kind {
SkillSourceKind::Host => query.include_host_skills,
SkillSourceKind::Executor => !query.executor_roots.is_empty(),
SkillSourceKind::Orchestrator => query.include_orchestrator_skills,
SkillSourceKind::Custom(_) => true,
}
}
fn owns_kind(&self, kind: &SkillSourceKind) -> bool {
&self.kind == kind
}
fn bind(&self, query: SkillListQuery) -> Arc<dyn SkillSource> {
Arc::new(BoundSkillProvider {
kind: self.kind.clone(),
provider: Arc::clone(&self.provider),
identity: self.identity.clone(),
host_snapshot: query.host_snapshot.clone(),
mcp_resources: query.mcp_resources.clone(),
query: Some(query),
})
}
}
impl fmt::Debug for SkillProviderSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SkillProviderSource")
.field("kind", &self.kind)
.field("label", &self.label)
.finish()
}
}
#[derive(Clone, Default, Debug)]
pub struct SkillProviders {
sources: Vec<SkillProviderSource>,
}
impl SkillProviders {
pub fn new() -> Self {
Self::default()
}
pub fn with_provider(mut self, source: SkillProviderSource) -> Self {
self.sources.push(source);
self
}
pub fn with_host_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
self.sources
.push(SkillProviderSource::host("host", provider));
self
}
pub fn with_executor_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
self.sources
.push(SkillProviderSource::executor("executor", provider));
self
}
pub fn with_orchestrator_provider(mut self, provider: Arc<dyn SkillProvider>) -> Self {
self.sources
.push(SkillProviderSource::orchestrator("orchestrator", provider));
self
}
pub(crate) fn has_orchestrator_provider(&self) -> bool {
self.sources
.iter()
.any(|source| source.kind == SkillSourceKind::Orchestrator)
}
pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog {
self.sources_for_turn(query).list().await
}
pub(crate) async fn list_orchestrator_for_turn(
&self,
query: SkillListQuery,
) -> SkillProviderResult<SkillCatalog> {
self.sources_for_turn(query)
.list_kind(&SkillSourceKind::Orchestrator)
.await
}
fn sources_for_turn(&self, query: SkillListQuery) -> SkillSources {
self.sources
.iter()
.filter(|source| source.should_list(&query))
.fold(SkillSources::new(), |sources, source| {
sources.with_source(source.label.clone(), source.bind(query.clone()))
})
}
pub(crate) async fn read(
&self,
request: SkillReadRequest,
) -> Result<SkillReadResult, SkillProviderError> {
let mut sources = self
.sources
.iter()
.filter(|source| source.owns_kind(&request.authority.kind));
let Some(source) = sources.next() else {
return Err(SkillProviderError::new(format!(
"{} skill provider is not configured",
request.authority.kind
)));
};
if sources.next().is_some() {
return Err(SkillProviderError::new(format!(
"{} skill authority is ambiguous",
request.authority.kind
)));
}
source.provider.read(request).await
}
pub async fn search(
&self,
request: SkillSearchRequest,
) -> Result<SkillSearchResult, SkillProviderError> {
let mut last_error = None;
for source in self
.sources
.iter()
.filter(|source| source.owns_kind(&request.authority.kind))
{
match source.provider.search(request.clone()).await {
Ok(result) => return Ok(result),
Err(err) => last_error = Some(err),
}
}
match last_error {
Some(err) => Err(err),
None => Err(SkillProviderError::new(format!(
"{} skill provider is not configured",
request.authority.kind
))),
}
}
}
struct BoundSkillProvider {
kind: SkillSourceKind,
provider: Arc<dyn SkillProvider>,
identity: SkillSourceIdentity,
host_snapshot: Option<Arc<codex_core_skills::HostSkillsSnapshot>>,
pub(crate) fn orchestrator_skill_sources(
thread_state: Arc<SkillsThreadState>,
mcp_resources: Option<Arc<McpResourceClient>>,
query: Option<SkillListQuery>,
) -> SkillSources {
SkillSources::new().with_source_factory(
"orchestrator",
Arc::new(move || {
orchestrator_skill_source(Arc::clone(&thread_state), mcp_resources.clone())
}),
)
}
impl SkillSource for BoundSkillProvider {
fn kind(&self) -> SkillSourceKind {
self.kind.clone()
}
pub(crate) fn orchestrator_skill_source(
thread_state: Arc<SkillsThreadState>,
mcp_resources: Option<Arc<McpResourceClient>>,
) -> Arc<dyn SkillSource> {
// Bind listing and reads to the manager generation current when this source is created.
let mcp_resources = mcp_resources.map(|client| Arc::new(client.snapshot()));
let identity = mcp_resources
.as_ref()
.map(|client| SkillSourceIdentity::from_owner(client.manager_snapshot()))
.unwrap_or_else(|| SkillSourceIdentity::from_owner(Arc::clone(&thread_state)));
Arc::new(OrchestratorSkillSource {
identity,
thread_state,
mcp_resources,
})
}
struct OrchestratorSkillSource {
identity: SkillSourceIdentity,
thread_state: Arc<SkillsThreadState>,
mcp_resources: Option<Arc<McpResourceClient>>,
}
impl SkillSource for OrchestratorSkillSource {
fn identity(&self) -> SkillSourceIdentity {
self.identity.clone()
}
fn list(&self) -> SkillSourceFuture<'_, SkillCatalog> {
let Some(query) = self.query.clone() else {
return Box::pin(async {
Err(SkillProviderError::new(
"skill source was not bound for catalog listing",
))
});
};
self.provider.list(query)
Box::pin(async move {
if !self.thread_state.orchestrator_skills_enabled() {
return Ok(SkillCatalog::default());
}
Ok(self
.thread_state
.orchestrator_catalog_snapshot(
self.mcp_resources.as_deref(),
discover_orchestrator_skills(self.mcp_resources.as_deref()),
)
.await)
})
}
fn read(&self, request: RuntimeSkillReadRequest) -> SkillSourceFuture<'_, SkillReadResult> {
self.provider.read(SkillReadRequest {
authority: request.authority,
package: request.package,
resource: request.resource,
host_snapshot: self.host_snapshot.clone(),
mcp_resources: self.mcp_resources.clone(),
fn read(&self, request: SkillReadRequest) -> SkillSourceFuture<'_, SkillReadResult> {
Box::pin(async move {
let read_request = request.clone();
self.thread_state
.read_orchestrator_resource(
&request,
self.mcp_resources.as_deref(),
read_orchestrator_skill(self.mcp_resources.as_deref(), read_request),
)
.await
})
}
}
async fn discover_orchestrator_skills(
client: Option<&McpResourceClient>,
) -> SkillSourceResult<SkillCatalog> {
let Some(client) = client else {
return Ok(SkillCatalog::default());
};
if !client.has_server(CODEX_APPS_MCP_SERVER_NAME).await {
return Ok(SkillCatalog::default());
}
let discovery_deadline = tokio::time::Instant::now() + ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT;
let mut catalog = SkillCatalog::default();
let mut cursor = None;
let mut seen_cursors = HashSet::new();
let mut skill_resources_seen = 0usize;
let mut skipped_resources = 0usize;
let mut truncated = false;
let mut completed_pages = 0usize;
for _ in 0..MAX_RESOURCE_PAGES {
let page = match tokio::time::timeout_at(
discovery_deadline,
client.list_resources(CODEX_APPS_MCP_SERVER_NAME, cursor.clone()),
)
.await
{
Ok(result) => result.map_err(|err| {
SkillSourceError::new(format!(
"failed to list orchestrator skill resources: {err:#}"
))
}),
Err(_) => Err(SkillSourceError::new(format!(
"orchestrator skill discovery timed out after {ORCHESTRATOR_SKILL_DISCOVERY_TIMEOUT:?}"
))),
};
let result = match page {
Ok(result) => result,
Err(err) if completed_pages == 0 => return Err(err),
Err(err) => {
let page_word = if completed_pages == 1 {
"page"
} else {
"pages"
};
catalog.warnings.push(format!(
"Orchestrator skill discovery stopped after {completed_pages} resource {page_word}: {}",
err.message
));
cursor = None;
break;
}
};
completed_pages = completed_pages.saturating_add(1);
for resource in &result.resources {
if resource.mime_type.as_deref() != Some(ORCHESTRATOR_SKILL_MIME_TYPE) {
continue;
}
if skill_resources_seen >= MAX_ORCHESTRATOR_SKILLS {
truncated = true;
break;
}
skill_resources_seen = skill_resources_seen.saturating_add(1);
match catalog_entry_from_resource(resource) {
Some(entry) => catalog.push_entry(entry),
None => skipped_resources = skipped_resources.saturating_add(1),
}
}
if truncated {
break;
}
let Some(next_cursor) = result.next_cursor else {
cursor = None;
break;
};
if !seen_cursors.insert(next_cursor.clone()) {
catalog.warnings.push(
"Orchestrator skill resource pagination returned a duplicate cursor.".to_string(),
);
cursor = None;
break;
}
cursor = Some(next_cursor);
}
if cursor.is_some() || truncated {
catalog.warnings.push(format!(
"Orchestrator skill discovery was truncated at {MAX_ORCHESTRATOR_SKILLS} skills or {MAX_RESOURCE_PAGES} resource pages."
));
}
if skipped_resources > 0 {
catalog.warnings.push(format!(
"Skipped {skipped_resources} malformed orchestrator skill resources."
));
}
Ok(catalog)
}
async fn read_orchestrator_skill(
client: Option<&McpResourceClient>,
request: SkillReadRequest,
) -> SkillSourceResult<SkillReadResult> {
if request.authority != orchestrator_authority() {
return Err(SkillSourceError::new(format!(
"orchestrator skill source cannot read authority {}",
request.authority.id
)));
}
if !resource_belongs_to_package(&request.package.0, request.resource.as_str()) {
return Err(SkillSourceError::new(
"orchestrator skill resource does not match its package",
));
}
let Some(client) = client else {
return Err(SkillSourceError::new(
"session MCP resource client is not configured",
));
};
let result = tokio::time::timeout(
ORCHESTRATOR_SKILL_READ_TIMEOUT,
client.read_resource(CODEX_APPS_MCP_SERVER_NAME, request.resource.as_str()),
)
.await
.map_err(|_| {
SkillSourceError::new(format!(
"orchestrator skill read timed out after {ORCHESTRATOR_SKILL_READ_TIMEOUT:?}"
))
})?
.map_err(|err| {
SkillSourceError::new(format!(
"failed to read orchestrator skill resource {}: {err:#}",
request.resource.as_str()
))
})?;
let contents = result
.contents
.into_iter()
.find_map(|contents| match contents {
ResourceContent::Text { uri, text, .. } if uri == request.resource.as_str() => {
Some(text)
}
ResourceContent::Text { .. } | ResourceContent::Blob { .. } => None,
});
let Some(contents) = contents else {
return Err(SkillSourceError::new(format!(
"orchestrator skill resource {} did not return matching text contents",
request.resource.as_str()
)));
};
if contents.len() > MAX_SKILL_RESOURCE_CONTENT_BYTES {
return Err(SkillSourceError::new(format!(
"orchestrator skill resource {} exceeds the {MAX_SKILL_RESOURCE_CONTENT_BYTES}-byte read limit",
request.resource.as_str()
)));
}
Ok(SkillReadResult {
resource: request.resource,
contents,
})
}
fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry> {
let uri = validated_skill_uri(resource.uri.as_str(), MAX_SKILL_PACKAGE_URI_CHARS)?;
let meta = resource.meta.as_ref()?.as_object()?;
let skill_name = normalized_label(meta.get("skill_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let name = if meta.get("source").and_then(|value| value.as_str()) == Some("user") {
skill_name
} else {
let plugin_name =
normalized_label(meta.get("plugin_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let qualified_name = format!("{plugin_name}:{skill_name}");
(qualified_name.chars().count() <= MAX_QUALIFIED_SKILL_NAME_CHARS)
.then_some(qualified_name)?
};
let description = normalized_description(resource.description.as_deref().unwrap_or_default())?;
let main_prompt = main_prompt_uri(uri);
Some(
SkillCatalogEntry::new(
SkillPackageId(uri.to_string()),
orchestrator_authority(),
name,
description,
SkillResourceId::new(main_prompt),
)
.with_display_path(uri),
)
}
fn orchestrator_authority() -> SkillAuthority {
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
}
fn validated_skill_uri(uri: &str, max_chars: usize) -> Option<&str> {
validated_skill_url(uri, max_chars).map(|_| uri)
}
fn validated_skill_url(uri: &str, max_chars: usize) -> Option<Url> {
if uri.chars().count() > max_chars
|| uri
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '<' | '>'))
{
return None;
}
let url = Url::parse(uri).ok()?;
let path_is_valid = url.path_segments().is_some_and(|segments| {
let segments = segments.collect::<Vec<_>>();
!segments.is_empty() && segments.iter().all(|segment| !segment.is_empty())
});
(url.scheme() == "skill"
&& url.as_str() == uri
&& url.host_str().is_some_and(|host| !host.is_empty())
&& url.username().is_empty()
&& url.password().is_none()
&& url.port().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
&& path_is_valid)
.then_some(url)
}
fn resource_belongs_to_package(package: &str, resource: &str) -> bool {
let Some(package) = validated_skill_url(package, MAX_SKILL_PACKAGE_URI_CHARS) else {
return false;
};
let Some(resource) = validated_skill_url(resource, MAX_SKILL_RESOURCE_URI_CHARS) else {
return false;
};
let Some(package_segments) = package.path_segments() else {
return false;
};
let Some(resource_segments) = resource.path_segments() else {
return false;
};
let package_segments = package_segments.collect::<Vec<_>>();
let resource_segments = resource_segments.collect::<Vec<_>>();
package.scheme() == resource.scheme()
&& package.host_str() == resource.host_str()
&& resource_segments.len() > package_segments.len()
&& resource_segments.starts_with(&package_segments)
}
fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
let value = normalized_single_line(value, max_chars)?;
let invalid = value.is_empty() || value.chars().any(|ch| matches!(ch, '&' | '<' | '>'));
(!invalid).then_some(value)
}
fn normalized_description(value: &str) -> Option<String> {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
if value.chars().any(char::is_control) {
return None;
}
Some(
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;"),
)
}
fn normalized_single_line(value: &str, max_chars: usize) -> Option<String> {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
let valid = value.chars().count() <= max_chars && !value.chars().any(char::is_control);
valid.then_some(value)
}
fn main_prompt_uri(package_uri: &str) -> String {
format!("{}/SKILL.md", package_uri.trim_end_matches('/'))
}
+39 -67
View File
@@ -2,99 +2,79 @@ use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_core_skills::runtime::SkillAuthority;
use codex_core_skills::runtime::SkillCatalog;
use codex_core_skills::runtime::SkillPackageId;
use codex_core_skills::runtime::SkillReadRequest;
use codex_core_skills::runtime::SkillReadResult;
use codex_core_skills::runtime::SkillResourceId;
use codex_core_skills::runtime::SkillSourceError;
use codex_core_skills::runtime::SkillSourceResult;
use codex_mcp::McpResourceClient;
use codex_mcp::McpResourceClientCacheKey;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use tokio::sync::OnceCell;
use crate::SkillsExtensionConfig;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillReadRequest;
use crate::sources::SkillProviders;
const MAX_CACHED_ORCHESTRATOR_RESOURCES: usize = 100;
const MAX_CACHED_ORCHESTRATOR_CONTENT_BYTES: usize = 8 * 1024 * 1024;
pub(crate) struct SkillsThreadState {
config: Mutex<SkillsExtensionConfig>,
selected_roots: Vec<SelectedCapabilityRoot>,
orchestrator_skills_enabled: AtomicBool,
orchestrator_skills_available: bool,
orchestrator_cache: Mutex<Option<Arc<OrchestratorGenerationCache>>>,
}
impl SkillsThreadState {
pub(crate) fn new(
config: SkillsExtensionConfig,
selected_roots: Vec<SelectedCapabilityRoot>,
orchestrator_skills_enabled: bool,
orchestrator_skills_available: bool,
) -> Self {
Self {
config: Mutex::new(config),
selected_roots,
orchestrator_skills_enabled: AtomicBool::new(orchestrator_skills_enabled),
orchestrator_skills_available,
orchestrator_cache: Mutex::new(None),
}
}
pub(crate) fn config(&self) -> SkillsExtensionConfig {
self.config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn set_config(&self, config: SkillsExtensionConfig) {
*self
.config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = config;
}
pub(crate) fn selected_roots(&self) -> &[SelectedCapabilityRoot] {
&self.selected_roots
pub(crate) fn set_orchestrator_skills_enabled(&self, enabled: bool) {
self.orchestrator_skills_enabled
.store(enabled, Ordering::Relaxed);
}
pub(crate) fn orchestrator_skills_enabled(&self) -> bool {
self.orchestrator_skills_available && self.config().orchestrator_skills_enabled
self.orchestrator_skills_available
&& self.orchestrator_skills_enabled.load(Ordering::Relaxed)
}
pub(crate) async fn orchestrator_catalog_snapshot(
&self,
mcp_resources: Option<&McpResourceClient>,
initialize: impl Future<Output = Result<SkillCatalog, SkillProviderError>> + Send,
initialize: impl Future<Output = Result<SkillCatalog, SkillSourceError>> + Send,
) -> SkillCatalog {
self.orchestrator_cache(mcp_resources)
.catalog
.get_or_init(|| async {
initialize.await.unwrap_or_else(|err| SkillCatalog {
warnings: vec![err.message],
..Default::default()
})
})
.await
.clone()
let cache = self.orchestrator_cache(mcp_resources);
if let Some(catalog) = cache.catalog.get() {
return catalog.clone();
}
let catalog = initialize.await.unwrap_or_else(|err| SkillCatalog {
warnings: vec![err.message],
..Default::default()
});
if !catalog.entries.is_empty() && catalog.warnings.is_empty() {
let _ = cache.catalog.set(catalog.clone());
}
catalog
}
pub(crate) async fn read_skill(
pub(crate) async fn read_orchestrator_resource(
&self,
providers: &SkillProviders,
request: SkillReadRequest,
) -> SkillProviderResult<SkillReadResult> {
if request.authority.kind != SkillSourceKind::Orchestrator {
return providers.read(request).await;
}
let cache = self.orchestrator_cache(request.mcp_resources.as_deref());
let cache_key = SkillReadCacheKey::from(&request);
request: &SkillReadRequest,
mcp_resources: Option<&McpResourceClient>,
read: impl Future<Output = SkillSourceResult<SkillReadResult>> + Send,
) -> SkillSourceResult<SkillReadResult> {
let cache_key = SkillReadCacheKey::from(request);
let cache = self.orchestrator_cache(mcp_resources);
if let Some(result) = cache
.resources
.lock()
@@ -104,7 +84,7 @@ impl SkillsThreadState {
return Ok(result);
}
let result = providers.read(request).await?;
let result = read.await?;
if result.resource != cache_key.resource {
return Ok(result);
}
@@ -196,11 +176,3 @@ impl OrchestratorResourceCache {
result
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SkillsTurnState {
pub(crate) catalog: SkillCatalog,
pub(crate) selected_entries: Vec<SkillCatalogEntry>,
pub(crate) warnings: Vec<String>,
pub(crate) main_prompts_injected: bool,
}
+3 -2
View File
@@ -7,9 +7,9 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillCatalogEntry;
use crate::render::truncate_catalog_skill_description;
use crate::render::truncate_utf8_to_bytes;
use codex_core_skills::runtime::SkillCatalogEntry;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
@@ -68,7 +68,8 @@ impl ToolExecutor<ToolCall> for ListTool {
Box::pin(async move {
let args: ListArgs = parse_args(&call)?;
let authority = args.authority.into_authority();
let catalog = self.context.catalog(&call.turn_id, args.authority).await;
let source = self.context.source();
let catalog = self.context.catalog(source.as_ref()).await;
let response = ListResponse {
skills: catalog
.entries
+17 -27
View File
@@ -1,5 +1,9 @@
use std::sync::Arc;
use codex_core_skills::runtime::SkillAuthority;
use codex_core_skills::runtime::SkillCatalog;
use codex_core_skills::runtime::SkillSource;
use codex_core_skills::runtime::SkillSourceKind;
use codex_extension_api::FunctionCallError;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ResponsesApiTool;
@@ -19,11 +23,7 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::sources::SkillProviders;
use crate::sources::orchestrator_skill_source;
use crate::state::SkillsThreadState;
mod list;
@@ -34,13 +34,11 @@ const SKILLS_NAMESPACE: &str = "skills";
const MAX_HANDLE_BYTES: usize = 2_048;
pub(crate) fn skill_tools(
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let context = SkillToolContext {
providers,
mcp_resources,
mcp_resources: mcp_resources.map(|client| Arc::new(client.snapshot())),
thread_state,
};
vec![
@@ -53,30 +51,22 @@ pub(crate) fn skill_tools(
#[derive(Clone)]
struct SkillToolContext {
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
}
impl SkillToolContext {
async fn catalog(&self, turn_id: &str, authority: SkillToolAuthority) -> SkillCatalog {
match authority {
SkillToolAuthority::Orchestrator => {
self.thread_state
.orchestrator_catalog_snapshot(
self.mcp_resources.as_deref(),
self.providers.list_orchestrator_for_turn(SkillListQuery {
turn_id: turn_id.to_string(),
executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: false,
include_orchestrator_skills: true,
mcp_resources: self.mcp_resources.clone(),
}),
)
.await
}
fn source(&self) -> Arc<dyn SkillSource> {
orchestrator_skill_source(Arc::clone(&self.thread_state), self.mcp_resources.clone())
}
async fn catalog(&self, source: &dyn SkillSource) -> SkillCatalog {
match source.list().await {
Ok(catalog) => catalog,
Err(err) => SkillCatalog {
warnings: vec![err.message],
..Default::default()
},
}
}
}
+11 -18
View File
@@ -1,3 +1,6 @@
use codex_core_skills::runtime::SkillPackageId;
use codex_core_skills::runtime::SkillReadRequest;
use codex_core_skills::runtime::SkillResourceId;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
@@ -8,10 +11,6 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillResourceId;
use crate::provider::SkillReadRequest;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
use super::SkillToolContext;
@@ -62,7 +61,8 @@ impl ToolExecutor<ToolCall> for ReadTool {
validate_handle("package", &args.package, MAX_HANDLE_BYTES)?;
validate_handle("resource", &args.resource, MAX_HANDLE_BYTES)?;
let catalog = self.context.catalog(&call.turn_id, args.authority).await;
let source = self.context.source();
let catalog = self.context.catalog(source.as_ref()).await;
let package_is_available = catalog.entries.iter().any(|entry| {
entry.enabled && entry.authority == authority && entry.id.0 == args.package
});
@@ -73,19 +73,12 @@ impl ToolExecutor<ToolCall> for ReadTool {
}
let requested_resource = SkillResourceId::new(args.resource);
let result = self
.context
.thread_state
.read_skill(
&self.context.providers,
SkillReadRequest {
authority,
package: SkillPackageId(args.package),
resource: requested_resource.clone(),
host_snapshot: None,
mcp_resources: self.context.mcp_resources.clone(),
},
)
let result = source
.read(SkillReadRequest {
authority,
package: SkillPackageId(args.package),
resource: requested_resource.clone(),
})
.await
.map_err(|err| {
tracing::warn!(
@@ -1,312 +0,0 @@
use std::io;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use codex_core_skills::HostSkillsSnapshot;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::ExecutorFileSystemFuture;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemReadStream;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::SkillScope;
use codex_skills_extension::ExecutorSkillProvider;
use codex_skills_extension::provider::SkillListQuery;
use codex_skills_extension::provider::SkillProvider;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
const SKILL_CONTENTS: &str =
"---\nname: synthetic\ndescription: Synthetic executor skill.\n---\n\nEXECUTOR_ONLY_BODY\n";
const PLUGIN_MANIFEST: &str = r#"{"name":"synthetic-plugin"}"#;
static NEXT_TEST_ROOT_ID: AtomicUsize = AtomicUsize::new(0);
struct SyntheticFileSystem {
alias_root: PathUri,
canonical_root: PathUri,
has_plugin_manifest: bool,
}
impl SyntheticFileSystem {
fn path(&self, relative_path: &str) -> io::Result<PathUri> {
self.canonical_root
.join(relative_path)
.map_err(io::Error::other)
}
async fn canonicalize(&self, path: &PathUri) -> io::Result<PathUri> {
if path == &self.alias_root {
return Ok(self.canonical_root.clone());
}
self.metadata(path)?;
Ok(path.clone())
}
async fn read_file(&self, path: &PathUri) -> io::Result<Vec<u8>> {
if path == &self.path("skill/SKILL.md")? {
Ok(SKILL_CONTENTS.as_bytes().to_vec())
} else if self.has_plugin_manifest && path == &self.path(".claude-plugin/plugin.json")? {
Ok(PLUGIN_MANIFEST.as_bytes().to_vec())
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
}
}
async fn read_directory(&self, path: &PathUri) -> io::Result<Vec<ReadDirectoryEntry>> {
if path == &self.canonical_root {
Ok(vec![ReadDirectoryEntry {
file_name: "skill".to_string(),
is_directory: true,
is_file: false,
}])
} else if path == &self.path("skill")? {
Ok(vec![ReadDirectoryEntry {
file_name: "SKILL.md".to_string(),
is_directory: false,
is_file: true,
}])
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
}
}
fn metadata(&self, path: &PathUri) -> io::Result<FileMetadata> {
let skill_dir = self.path("skill")?;
let skill_path = self.path("skill/SKILL.md")?;
let manifest_path = self.path(".claude-plugin/plugin.json")?;
let (is_directory, is_file) = if path == &self.canonical_root || path == &skill_dir {
(true, false)
} else if path == &skill_path || self.has_plugin_manifest && path == &manifest_path {
(false, true)
} else {
return Err(io::Error::new(io::ErrorKind::NotFound, "not found"));
};
Ok(FileMetadata {
is_directory,
is_file,
is_symlink: false,
size: 0,
created_at_ms: 0,
modified_at_ms: 0,
})
}
}
impl ExecutorFileSystem for SyntheticFileSystem {
fn canonicalize<'a>(
&'a self,
path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, PathUri> {
Box::pin(SyntheticFileSystem::canonicalize(self, path))
}
fn read_file<'a>(
&'a self,
path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
Box::pin(SyntheticFileSystem::read_file(self, path))
}
fn read_file_stream<'a>(
&'a self,
_path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
Box::pin(async {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"synthetic filesystem does not support streaming reads",
))
})
}
fn write_file<'a>(
&'a self,
_path: &'a PathUri,
_contents: Vec<u8>,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
Box::pin(async move { Err(io::Error::new(io::ErrorKind::Unsupported, "read only")) })
}
fn create_directory<'a>(
&'a self,
_path: &'a PathUri,
_options: CreateDirectoryOptions,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
Box::pin(async move { Err(io::Error::new(io::ErrorKind::Unsupported, "read only")) })
}
fn get_metadata<'a>(
&'a self,
path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
Box::pin(async move { self.metadata(path) })
}
fn read_directory<'a>(
&'a self,
path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<ReadDirectoryEntry>> {
Box::pin(SyntheticFileSystem::read_directory(self, path))
}
fn remove<'a>(
&'a self,
_path: &'a PathUri,
_options: RemoveOptions,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
Box::pin(async move { Err(io::Error::new(io::ErrorKind::Unsupported, "read only")) })
}
fn copy<'a>(
&'a self,
_source_path: &'a PathUri,
_destination_path: &'a PathUri,
_options: CopyOptions,
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
Box::pin(async move { Err(io::Error::new(io::ErrorKind::Unsupported, "read only")) })
}
}
#[tokio::test]
async fn skill_loading_and_reads_use_the_supplied_executor_file_system() {
let test_root =
std::env::temp_dir().join(format!("codex-executor-skill-fs-{}", std::process::id()));
let alias_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("alias"))
.expect("absolute path");
let canonical_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("canonical"))
.expect("absolute path");
assert!(!alias_root.as_path().exists());
assert!(!canonical_root.as_path().exists());
let outcome = load_skills_from_roots(
[SkillRoot {
path: alias_root.clone(),
scope: SkillScope::User,
file_system: Arc::new(SyntheticFileSystem {
alias_root: PathUri::from_abs_path(&alias_root),
canonical_root: PathUri::from_abs_path(&canonical_root),
has_plugin_manifest: false,
}),
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
}],
/*plugin_skill_snapshots*/ None,
)
.await;
assert_eq!(outcome.errors, Vec::new());
assert_eq!(outcome.skills.len(), 1);
let skill = outcome.skills[0].clone();
assert_eq!(skill.name, "synthetic");
assert_eq!(
skill.path_to_skills_md,
canonical_root.join("skill/SKILL.md")
);
let loaded = HostSkillsSnapshot::new(Arc::new(outcome));
assert_eq!(
loaded.read_skill_text(&skill).await.expect("skill body"),
SKILL_CONTENTS
);
}
#[tokio::test]
async fn selected_root_id_distinguishes_identical_executor_paths() {
let root_label = if cfg!(unix) {
r"root\identity"
} else {
"root-identity"
};
let test_root = create_local_skill_root(root_label).expect("create local skill root");
let selected_root = test_root.to_string_lossy().into_owned();
let selected_root = if cfg!(windows) {
selected_root.replace('\\', "/")
} else {
selected_root
};
let provider = ExecutorSkillProvider::new_with_restriction_product(
Arc::new(EnvironmentManager::default_for_tests()),
/*restriction_product*/ None,
);
let catalog = provider
.list(SkillListQuery {
turn_id: "turn-1".to_string(),
executor_roots: ["root-a", "root-b"]
.into_iter()
.map(|id| SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "local".to_string(),
path: PathUri::from_host_native_path(&test_root).expect("skill root URI"),
},
})
.collect(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: true,
include_orchestrator_skills: false,
mcp_resources: None,
})
.await
.expect("list executor skills");
assert_eq!(
catalog
.entries
.iter()
.map(|entry| (
entry.authority.id.clone(),
entry.display_path.clone().expect("display path"),
))
.collect::<Vec<_>>(),
vec![
(
"root-a".to_string(),
format!(
"skill://root-a/{}/skill/SKILL.md",
selected_root.trim_start_matches('/')
),
),
(
"root-b".to_string(),
format!(
"skill://root-b/{}/skill/SKILL.md",
selected_root.trim_start_matches('/')
),
),
]
);
std::fs::remove_dir_all(test_root).expect("remove skill directory");
}
fn create_local_skill_root(label: &str) -> io::Result<std::path::PathBuf> {
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
let test_root = std::env::temp_dir().join(format!(
"codex-executor-skill-{label}-{}-{id}",
std::process::id()
));
let skill_dir = test_root.join("skill");
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(skill_dir.join("SKILL.md"), SKILL_CONTENTS)?;
Ok(test_root)
}
@@ -1,738 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use codex_core_skills::HostSkillsSnapshot;
use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS;
use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS;
use codex_core_skills::SkillLoadOutcome;
use codex_core_skills::SkillMetadata;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_extension_api::ConversationHistory;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::NoopTurnItemEmitter;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolPayload;
use codex_extension_api::TurnInputContext;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::SkillProviders;
use codex_skills_extension::SkillsExtensionConfig;
use codex_skills_extension::catalog::SkillAuthority;
use codex_skills_extension::catalog::SkillCatalog;
use codex_skills_extension::catalog::SkillCatalogEntry;
use codex_skills_extension::catalog::SkillPackageId;
use codex_skills_extension::catalog::SkillProviderError;
use codex_skills_extension::catalog::SkillReadResult;
use codex_skills_extension::catalog::SkillResourceId;
use codex_skills_extension::catalog::SkillSearchResult;
use codex_skills_extension::catalog::SkillSourceKind;
use codex_skills_extension::install;
use codex_skills_extension::install_with_providers;
use codex_skills_extension::provider::SkillListQuery;
use codex_skills_extension::provider::SkillProvider;
use codex_skills_extension::provider::SkillProviderFuture;
use codex_skills_extension::provider::SkillReadRequest;
use codex_skills_extension::provider::SkillSearchRequest;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
type TestResult = Result<(), Box<dyn std::error::Error>>;
static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
const DEMO_SKILL_CONTENTS: &str =
"---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n";
#[tokio::test]
async fn installed_extension_uses_host_service_snapshot() -> TestResult {
let codex_home = test_codex_home();
let skill_path = codex_home.join("skills").join("demo").join("SKILL.md");
std::fs::create_dir_all(
skill_path
.parent()
.ok_or("skill path should have a parent")?,
)?;
std::fs::write(&skill_path, DEMO_SKILL_CONTENTS)?;
let config = default_config();
let mut builder = ExtensionRegistryBuilder::new();
install(&mut builder, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let session_source = SessionSource::Cli;
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let skill_path = AbsolutePathBuf::try_from(skill_path)?;
let skill_path_string = skill_path.to_string_lossy().into_owned();
let mut outcome = SkillLoadOutcome::default();
outcome.skills.push(SkillMetadata {
name: "demo".to_string(),
description: "Demo skill.".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: skill_path,
scope: SkillScope::User,
plugin_id: None,
});
let loaded_skills = Arc::new(outcome);
let skill_prompt_path = skill_path_string.replace('\\', "/");
let turn_store = ExtensionData::new("turn-1");
turn_store.insert(HostSkillsSnapshot::new(Arc::clone(&loaded_skills)));
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-1".to_string(),
user_input: vec![UserInput::Text {
text: "$demo".to_string(),
text_elements: Vec::new(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&turn_store,
)
.await;
let expected_catalog = format!(
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n## Skills\n{SKILLS_INTRO_WITH_ABSOLUTE_PATHS}\n### Available skills\n- demo: Demo skill. (file: {skill_prompt_path})\n### How to use skills\n{SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
);
let expected_skill = format!(
"<skill>\n<name>demo</name>\n<path>{skill_prompt_path}</path>\n{DEMO_SKILL_CONTENTS}\n</skill>"
);
assert_eq!(
vec![("developer", expected_catalog), ("user", expected_skill),],
fragments
.iter()
.map(|fragment| (fragment.role(), fragment.render()))
.collect::<Vec<_>>()
);
let injected_host_skill_prompts = turn_store
.get::<InjectedHostSkillPrompts>()
.ok_or("host skill prompt marker should be set")?;
assert!(injected_host_skill_prompts.contains_path(&skill_path_string));
std::fs::remove_dir_all(codex_home)?;
Ok(())
}
#[tokio::test]
async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_input() -> TestResult
{
let read_requests = Arc::new(Mutex::new(Vec::new()));
let executor_provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![test_entry(
SkillSourceKind::Executor,
"env-1",
"executor/lint-fix",
"lint-fix/SKILL.md",
)],
warnings: Vec::new(),
},
read_requests: Arc::clone(&read_requests),
list_calls: None,
fail_first_list: false,
});
let providers = SkillProviders::new().with_executor_provider(executor_provider);
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
thread_store.insert(vec![SelectedCapabilityRoot {
id: "lint-fix".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "env-1".to_string(),
path: PathUri::parse("file:///skills/lint-fix").expect("skill root URI"),
},
}]);
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let prompt_fragments = registry.context_contributors()[0]
.contribute_thread_context(&session_store, &thread_store)
.await;
assert_eq!(1, prompt_fragments.len());
assert!(
prompt_fragments[0]
.text()
.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
);
assert!(prompt_fragments[0].text().contains("lint-fix"));
assert!(
prompt_fragments[0]
.text()
.contains("(environment resource: skill://executor/lint-fix/SKILL.md)")
);
let turn_store = ExtensionData::new("turn-1");
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-1".to_string(),
user_input: vec![UserInput::Text {
text: "$lint-fix please".to_string(),
text_elements: Vec::new(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&turn_store,
)
.await;
assert_eq!(1, fragments.len());
assert_eq!("user", fragments[0].role());
assert!(fragments[0].render().contains("<name>lint-fix</name>"));
assert!(fragments[0].render().contains("# Lint Fix"));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Executor, "env-1"),
SkillPackageId("executor/lint-fix".to_string()),
SkillResourceId::new("lint-fix/SKILL.md"),
)],
read_request_keys(&read_requests)
);
let rebuilt_prompt_fragments = registry.context_contributors()[0]
.contribute_thread_context(&session_store, &thread_store)
.await;
assert_eq!(1, rebuilt_prompt_fragments.len());
assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix"));
let next_turn_store = ExtensionData::new("turn-2");
let next_fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-2".to_string(),
user_input: vec![UserInput::Text {
text: "no skill this time".to_string(),
text_elements: Vec::new(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&next_turn_store,
)
.await;
assert!(next_fragments.is_empty());
Ok(())
}
#[tokio::test]
async fn default_context_truncates_catalog_descriptions() -> TestResult {
let description = "x".repeat(1_025);
let mut entry = test_entry(
SkillSourceKind::Orchestrator,
"codex_apps",
"orchestrator/long-description",
"skill://orchestrator/long-description/SKILL.md",
);
entry.description = description.clone();
let providers =
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![entry],
warnings: Vec::new(),
},
read_requests: Arc::new(Mutex::new(Vec::new())),
list_calls: None,
fail_first_list: false,
}));
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let fragments = registry.context_contributors()[0]
.contribute_thread_context(&session_store, &thread_store)
.await;
assert_eq!(1, fragments.len());
let rendered = fragments[0].text();
assert!(rendered.contains(&("x".repeat(1_021) + "...")));
assert!(!rendered.contains(&"x".repeat(1_024)));
assert!(!rendered.contains(&description));
Ok(())
}
#[tokio::test]
async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult {
let description = "x".repeat(1_025);
let mut entry = test_entry(
SkillSourceKind::Orchestrator,
"codex_apps",
"orchestrator/long-description",
"skill://orchestrator/long-description/SKILL.md",
);
entry.description = description.clone();
let providers =
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![entry],
warnings: Vec::new(),
},
read_requests: Arc::new(Mutex::new(Vec::new())),
list_calls: None,
fail_first_list: false,
}));
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store);
let list_tool = tools
.iter()
.find(|tool| tool.tool_name().name == "list")
.ok_or("skills.list tool should be registered")?;
let payload = ToolPayload::Function {
arguments: serde_json::json!({"authority": {"kind": "orchestrator"}}).to_string(),
};
let output = list_tool
.handle(ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: list_tool.tool_name(),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1_024),
conversation_history: ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
environments: Vec::new(),
payload: payload.clone(),
})
.await?;
let response = output
.post_tool_use_response("call-1", &payload)
.ok_or("skills.list should expose structured output")?;
let rendered_description = response["skills"][0]["description"]
.as_str()
.ok_or("skills.list response should include a description")?;
assert_eq!(rendered_description, "x".repeat(1_021) + "...");
assert_ne!(rendered_description, description);
Ok(())
}
#[tokio::test]
async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult {
let list_calls = Arc::new(AtomicUsize::new(0));
let providers =
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![test_entry(
SkillSourceKind::Orchestrator,
"codex_apps",
"orchestrator/first",
"skill://orchestrator/first/SKILL.md",
)],
warnings: Vec::new(),
},
read_requests: Arc::new(Mutex::new(Vec::new())),
list_calls: Some(Arc::clone(&list_calls)),
fail_first_list: true,
}));
let (event_tx, event_rx) = std::sync::mpsc::channel();
let mut builder =
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let initial_fragments = registry.context_contributors()[0]
.contribute_thread_context(&session_store, &thread_store)
.await;
assert!(initial_fragments.is_empty());
let EventMsg::Warning(warning) = event_rx.try_recv()?.msg else {
panic!("expected warning event");
};
assert_eq!(
warning.message,
"orchestrator skills unavailable: temporary orchestrator failure"
);
for turn_id in ["turn-1", "turn-2"] {
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: turn_id.to_string(),
user_input: vec![UserInput::Text {
text: "$first".to_string(),
text_elements: Vec::new(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&ExtensionData::new(turn_id),
)
.await;
assert!(fragments.is_empty());
}
assert_eq!(1, list_calls.load(Ordering::Relaxed));
Ok(())
}
#[tokio::test]
async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> TestResult {
let read_requests = Arc::new(Mutex::new(Vec::new()));
let root_a_locator = "skill://root-a/shared/lint-fix/SKILL.md";
let root_b_locator = "skill://root-b/shared/lint-fix/SKILL.md";
let executor_provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: [("root-a", root_a_locator), ("root-b", root_b_locator)]
.into_iter()
.map(|(root_id, locator)| {
SkillCatalogEntry::new(
SkillPackageId(locator.to_string()),
SkillAuthority::new(SkillSourceKind::Executor, root_id),
"lint-fix",
"Fix lint errors.",
SkillResourceId::new(locator),
)
.with_display_path(locator)
})
.collect(),
warnings: Vec::new(),
},
read_requests: Arc::clone(&read_requests),
list_calls: None,
fail_first_list: false,
});
let providers = SkillProviders::new().with_executor_provider(executor_provider);
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
thread_store.insert(
[("root-a", "/skills/root-a"), ("root-b", "/skills/root-b")]
.into_iter()
.map(|(id, path)| SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "env-1".to_string(),
path: PathUri::parse(&format!("file://{path}")).expect("skill root URI"),
},
})
.collect::<Vec<_>>(),
);
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-1".to_string(),
user_input: vec![UserInput::Mention {
name: "lint-fix".to_string(),
path: root_b_locator.to_string(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&ExtensionData::new("turn-1"),
)
.await;
assert_eq!(1, fragments.len());
assert!(fragments[0].render().contains(root_b_locator));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Executor, "root-b"),
SkillPackageId(root_b_locator.to_string()),
SkillResourceId::new(root_b_locator),
)],
read_request_keys(&read_requests)
);
Ok(())
}
#[tokio::test]
async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult {
let read_requests = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![
test_entry(
SkillSourceKind::Host,
"host",
"host/visible-skill",
"visible-skill/SKILL.md",
),
test_entry(
SkillSourceKind::Host,
"host",
"host/hidden-skill",
"hidden-skill/SKILL.md",
)
.hidden_from_prompt(),
],
warnings: Vec::new(),
},
read_requests: Arc::clone(&read_requests),
list_calls: None,
fail_first_list: false,
});
let providers = SkillProviders::new().with_host_provider(provider);
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let session_source = SessionSource::Cli;
let config = default_config();
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-1".to_string(),
user_input: vec![UserInput::Text {
text: "$hidden-skill".to_string(),
text_elements: Vec::new(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&ExtensionData::new("turn-1"),
)
.await;
assert_eq!(2, fragments.len());
assert!(fragments[0].render().contains("visible-skill"));
assert!(!fragments[0].render().contains("hidden-skill"));
assert!(fragments[1].render().contains("<name>hidden-skill</name>"));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Host, "host"),
SkillPackageId("host/hidden-skill".to_string()),
SkillResourceId::new("hidden-skill/SKILL.md"),
)],
read_request_keys(&read_requests)
);
Ok(())
}
#[derive(Clone)]
struct StaticSkillProvider {
catalog: SkillCatalog,
read_requests: Arc<Mutex<Vec<SkillReadRequest>>>,
list_calls: Option<Arc<AtomicUsize>>,
fail_first_list: bool,
}
struct ChannelEventSink(std::sync::mpsc::Sender<Event>);
impl ExtensionEventSink for ChannelEventSink {
fn emit(&self, event: Event) {
let _ = self.0.send(event);
}
}
impl SkillProvider for StaticSkillProvider {
fn list(&self, _query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
let list_call = self
.list_calls
.as_ref()
.map(|list_calls| list_calls.fetch_add(1, Ordering::Relaxed));
let fail = self.fail_first_list && list_call == Some(0);
let catalog = self.catalog.clone();
Box::pin(async move {
if fail {
Err(SkillProviderError::new("temporary orchestrator failure"))
} else {
Ok(catalog)
}
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
let read_requests = Arc::clone(&self.read_requests);
Box::pin(async move {
read_requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request.clone());
Ok(SkillReadResult {
resource: request.resource,
contents: "# Lint Fix\n\nRun the formatter.".to_string(),
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn test_entry(
kind: SkillSourceKind,
authority_id: &str,
package_id: &str,
main_prompt: &str,
) -> SkillCatalogEntry {
let name = package_id.rsplit('/').next().unwrap_or(package_id);
SkillCatalogEntry::new(
SkillPackageId(package_id.to_string()),
SkillAuthority::new(kind, authority_id),
name,
"Fix lint errors.",
SkillResourceId::new(main_prompt),
)
.with_display_path(format!("skill://{package_id}/SKILL.md"))
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct TestConfig {
include_instructions: bool,
bundled_skills_enabled: bool,
orchestrator_skills_enabled: bool,
}
fn default_config() -> TestConfig {
TestConfig {
include_instructions: true,
bundled_skills_enabled: true,
orchestrator_skills_enabled: true,
}
}
fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig {
SkillsExtensionConfig {
include_instructions: config.include_instructions,
bundled_skills_enabled: config.bundled_skills_enabled,
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
}
}
fn test_codex_home() -> PathBuf {
let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"codex-skills-extension-test-{}-{id}",
std::process::id(),
))
}
fn read_request_keys(
requests: &Arc<Mutex<Vec<SkillReadRequest>>>,
) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> {
requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.map(|request| {
(
request.authority.clone(),
request.package.clone(),
request.resource.clone(),
)
})
.collect()
}