Fix multi-env skill identity handling

This commit is contained in:
starr-openai
2026-05-29 01:30:01 -07:00
parent 21a09d1399
commit d42cf80218
21 changed files with 379 additions and 123 deletions

View File

@@ -17,12 +17,12 @@ const SKILLS_LIST_CWD_CONCURRENCY: usize = 5;
fn skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
disabled_paths: &HashSet<codex_exec_server::EnvironmentPathRef>,
) -> Vec<codex_app_server_protocol::SkillMetadata> {
skills
.iter()
.map(|skill| {
let enabled = !disabled_paths.contains(&skill.path_to_skills_md);
let enabled = !disabled_paths.contains(&skill.source_path);
codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),

View File

@@ -666,7 +666,10 @@ pub async fn load_plugin_skills(
.into_iter()
.filter(|skill| skill.matches_product_restriction_for_product(restriction_product))
.collect::<Vec<_>>();
let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules);
let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules)
.into_iter()
.map(|path| path.path().clone())
.collect();
ResolvedPluginSkills {
skills,

View File

@@ -5,6 +5,7 @@ use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::SkillConfig;
use codex_config::SkillsConfig;
use codex_exec_server::EnvironmentPathRef;
use codex_utils_absolute_path::AbsolutePathBuf;
use tracing::warn;
@@ -71,28 +72,34 @@ pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> S
pub fn resolve_disabled_skill_paths(
skills: &[SkillMetadata],
rules: &SkillConfigRules,
) -> HashSet<AbsolutePathBuf> {
) -> HashSet<EnvironmentPathRef> {
let mut disabled_paths = HashSet::new();
for entry in &rules.entries {
match &entry.selector {
SkillConfigRuleSelector::Path(path) => {
if entry.enabled {
disabled_paths.remove(path);
} else {
disabled_paths.insert(path.clone());
for source_path in skills
.iter()
.filter(|skill| skill.path_to_skills_md == *path)
.map(|skill| skill.source_path.clone())
{
if entry.enabled {
disabled_paths.remove(&source_path);
} else {
disabled_paths.insert(source_path);
}
}
}
SkillConfigRuleSelector::Name(name) => {
for path in skills
for source_path in skills
.iter()
.filter(|skill| skill.name == *name)
.map(|skill| skill.path_to_skills_md.clone())
.map(|skill| skill.source_path.clone())
{
if entry.enabled {
disabled_paths.remove(&path);
disabled_paths.remove(&source_path);
} else {
disabled_paths.insert(path);
disabled_paths.insert(source_path);
}
}
}

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::collections::HashSet;
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::EnvironmentPathRef;
use codex_otel::SessionTelemetry;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -28,7 +28,6 @@ pub struct SkillInjection {
pub async fn build_skill_injections(
mentioned_skills: &[SkillMetadata],
_loaded_skills: Option<&SkillLoadOutcome>,
otel: Option<&SessionTelemetry>,
analytics_client: &AnalyticsEventsClient,
tracking: TrackEventsContext,
@@ -107,7 +106,7 @@ fn emit_skill_injected_metric(
pub fn collect_explicit_skill_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
disabled_paths: &HashSet<EnvironmentPathRef>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let skill_name_counts = build_skill_name_counts(skills, disabled_paths).0;
@@ -120,7 +119,7 @@ pub fn collect_explicit_skill_mentions(
};
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 seen_paths: HashSet<EnvironmentPathRef> = HashSet::new();
let mut blocked_plain_names: HashSet<String> = HashSet::new();
for input in inputs {
@@ -130,18 +129,22 @@ pub fn collect_explicit_skill_mentions(
continue;
};
if selection_context.disabled_paths.contains(&path) || seen_paths.contains(&path) {
continue;
}
if let Some(skill) = selection_context
let matching_skills = selection_context
.skills
.iter()
.find(|skill| skill.path_to_skills_md == path)
.filter(|skill| {
skill.path_to_skills_md == path
&& !selection_context
.disabled_paths
.contains(&skill.source_path)
})
.collect::<Vec<_>>();
if let [skill] = matching_skills.as_slice()
&& !seen_paths.contains(&skill.source_path)
{
seen_paths.insert(skill.path_to_skills_md.clone());
seen_paths.insert(skill.source_path.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
selected.push((*skill).clone());
}
}
}
@@ -165,7 +168,7 @@ pub fn collect_explicit_skill_mentions(
struct SkillSelectionContext<'a> {
skills: &'a [SkillMetadata],
disabled_paths: &'a HashSet<AbsolutePathBuf>,
disabled_paths: &'a HashSet<EnvironmentPathRef>,
skill_name_counts: &'a HashMap<String, usize>,
connector_slug_counts: &'a HashMap<String, usize>,
}
@@ -316,7 +319,7 @@ fn select_skills_from_mentions(
blocked_plain_names: &HashSet<String>,
mentions: &ToolMentions<'_>,
seen_names: &mut HashSet<String>,
seen_paths: &mut HashSet<AbsolutePathBuf>,
seen_paths: &mut HashSet<EnvironmentPathRef>,
selected: &mut Vec<SkillMetadata>,
) {
if mentions.is_empty() {
@@ -334,18 +337,39 @@ fn select_skills_from_mentions(
.map(normalize_skill_path)
.collect();
let mention_skill_path_counts = selection_context
.skills
.iter()
.filter(|skill| {
!selection_context
.disabled_paths
.contains(&skill.source_path)
})
.filter_map(|skill| {
let path = skill.path_to_skills_md.to_string_lossy();
mention_skill_paths
.contains(path.as_ref())
.then_some(path.into_owned())
})
.fold(HashMap::new(), |mut counts, path| {
*counts.entry(path).or_insert(0usize) += 1;
counts
});
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)
.contains(&skill.source_path)
|| seen_paths.contains(&skill.source_path)
{
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());
if mention_skill_paths.contains(path_str.as_ref())
&& mention_skill_path_counts.get(path_str.as_ref()) == Some(&1)
{
seen_paths.insert(skill.source_path.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
@@ -354,8 +378,8 @@ fn select_skills_from_mentions(
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)
.contains(&skill.source_path)
|| seen_paths.contains(&skill.source_path)
{
continue;
}
@@ -382,7 +406,7 @@ fn select_skills_from_mentions(
}
if seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_paths.insert(skill.source_path.clone());
selected.push(skill.clone());
}
}

View File

@@ -1,10 +1,12 @@
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_exec_server::EnvironmentPathRef;
use codex_exec_server::LocalFileSystem;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
fn make_skill(name: &str, path: &str) -> SkillMetadata {
SkillMetadata {
@@ -39,7 +41,7 @@ fn linked_skill_mention(name: &str, unix_path: &str) -> String {
fn collect_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
disabled_paths: &HashSet<EnvironmentPathRef>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts)
@@ -206,7 +208,7 @@ fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fa
path: test_path_buf("/tmp/alpha"),
},
];
let disabled = HashSet::from([test_path_buf("/tmp/alpha").abs()]);
let disabled = HashSet::from([EnvironmentPathRef::local(test_path_buf("/tmp/alpha").abs())]);
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
@@ -230,6 +232,27 @@ fn collect_explicit_skill_mentions_dedupes_by_path() {
assert_eq!(selected, vec![alpha]);
}
#[test]
fn collect_explicit_skill_mentions_skips_ambiguous_same_path_across_envs() {
let mut devbox = make_skill("demo-skill", "/tmp/demo/SKILL.md");
devbox.environment_id = "devbox".to_string();
devbox.source_path = EnvironmentPathRef::new(
Arc::new(LocalFileSystem::unsandboxed()),
test_path_buf("/tmp/demo/SKILL.md").abs(),
);
let local = make_skill("demo-skill", "/tmp/demo/SKILL.md");
let skills = vec![devbox, local];
let inputs = vec![UserInput::Skill {
name: "demo-skill".to_string(),
path: test_path_buf("/tmp/demo/SKILL.md"),
}];
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_ambiguous_name() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
@@ -304,7 +327,7 @@ fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() {
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 disabled = HashSet::from([EnvironmentPathRef::local(test_path_buf("/tmp/alpha").abs())]);
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);

View File

@@ -3,22 +3,24 @@ use std::path::Path;
use crate::SkillLoadOutcome;
use crate::SkillMetadata;
use codex_exec_server::EnvironmentPathRef;
use codex_utils_absolute_path::AbsolutePathBuf;
pub(crate) fn build_implicit_skill_path_indexes(
skills: Vec<SkillMetadata>,
) -> (
HashMap<AbsolutePathBuf, SkillMetadata>,
HashMap<AbsolutePathBuf, SkillMetadata>,
HashMap<EnvironmentPathRef, SkillMetadata>,
HashMap<EnvironmentPathRef, SkillMetadata>,
) {
let mut by_scripts_dir = HashMap::new();
let mut by_skill_doc_path = HashMap::new();
for skill in skills {
let skill_doc_path = canonicalize_if_exists(&skill.path_to_skills_md);
let skill_doc_path = canonicalize_if_exists(&skill.source_path);
by_skill_doc_path.insert(skill_doc_path, skill.clone());
if let Some(skill_dir) = skill.path_to_skills_md.parent() {
let scripts_dir = canonicalize_if_exists(&skill_dir.join("scripts"));
let scripts_dir =
canonicalize_if_exists(&skill.source_path.with_path(skill_dir.join("scripts")));
by_scripts_dir.insert(scripts_dir, skill);
}
}
@@ -29,7 +31,7 @@ pub(crate) fn build_implicit_skill_path_indexes(
pub fn detect_implicit_skill_invocation_for_command(
outcome: &SkillLoadOutcome,
command: &str,
workdir: &AbsolutePathBuf,
workdir: &EnvironmentPathRef,
) -> Option<SkillMetadata> {
let workdir = canonicalize_if_exists(workdir);
let tokens = tokenize_command(command);
@@ -81,14 +83,20 @@ fn script_run_token(tokens: &[String]) -> Option<&str> {
fn detect_skill_script_run(
outcome: &SkillLoadOutcome,
tokens: &[String],
workdir: &AbsolutePathBuf,
workdir: &EnvironmentPathRef,
) -> Option<SkillMetadata> {
let script_token = script_run_token(tokens)?;
let script_path = Path::new(script_token);
let script_path = canonicalize_if_exists(&workdir.join(script_path));
let script_path = canonicalize_if_exists(&resolve_path_ref(workdir, script_path)?);
for path in script_path.ancestors() {
if let Some(candidate) = outcome.implicit_skills_by_scripts_dir.get(&path) {
for path in script_path.path().ancestors() {
let Ok(path) = AbsolutePathBuf::from_absolute_path(path) else {
continue;
};
if let Some(candidate) = outcome
.implicit_skills_by_scripts_dir
.get(&script_path.with_path(path))
{
return Some(candidate.clone());
}
}
@@ -99,7 +107,7 @@ fn detect_skill_script_run(
fn detect_skill_doc_read(
outcome: &SkillLoadOutcome,
tokens: &[String],
workdir: &AbsolutePathBuf,
workdir: &EnvironmentPathRef,
) -> Option<SkillMetadata> {
if !command_reads_file(tokens) {
return None;
@@ -110,7 +118,7 @@ fn detect_skill_doc_read(
continue;
}
let path = Path::new(token);
let candidate_path = canonicalize_if_exists(&workdir.join(path));
let candidate_path = canonicalize_if_exists(&resolve_path_ref(workdir, path)?);
if let Some(candidate) = outcome.implicit_skills_by_doc_path.get(&candidate_path) {
return Some(candidate.clone());
}
@@ -136,8 +144,22 @@ fn command_basename(command: &str) -> String {
.to_string()
}
fn canonicalize_if_exists(path: &AbsolutePathBuf) -> AbsolutePathBuf {
path.canonicalize().unwrap_or_else(|_| path.clone())
fn resolve_path_ref(workdir: &EnvironmentPathRef, path: &Path) -> Option<EnvironmentPathRef> {
if path.is_absolute() {
AbsolutePathBuf::from_absolute_path(path)
.ok()
.map(|path| workdir.with_path(path))
} else {
workdir.join_relative(path)
}
}
fn canonicalize_if_exists(path: &EnvironmentPathRef) -> EnvironmentPathRef {
path.with_path(
path.path()
.canonicalize()
.unwrap_or_else(|_| path.path().clone()),
)
}
#[cfg(test)]

View File

@@ -4,6 +4,7 @@ use super::canonicalize_if_exists;
use super::detect_skill_doc_read;
use super::detect_skill_script_run;
use super::script_run_token;
use codex_exec_server::EnvironmentPathRef;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
@@ -56,7 +57,8 @@ fn script_run_detection_excludes_python_c() {
#[test]
fn skill_doc_read_detection_matches_absolute_path() {
let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs();
let normalized_skill_doc_path = canonicalize_if_exists(&skill_doc_path);
let normalized_skill_doc_path =
canonicalize_if_exists(&EnvironmentPathRef::local(skill_doc_path.clone()));
let skill = test_skill_metadata(skill_doc_path);
let outcome = SkillLoadOutcome {
implicit_skills_by_scripts_dir: Arc::new(HashMap::new()),
@@ -70,7 +72,11 @@ fn skill_doc_read_detection_matches_absolute_path() {
"|".to_string(),
"head".to_string(),
];
let found = detect_skill_doc_read(&outcome, &tokens, &test_path_buf("/tmp").abs());
let found = detect_skill_doc_read(
&outcome,
&tokens,
&EnvironmentPathRef::local(test_path_buf("/tmp").abs()),
);
assert_eq!(
found.map(|value| value.name),
@@ -81,7 +87,9 @@ fn skill_doc_read_detection_matches_absolute_path() {
#[test]
fn skill_script_run_detection_matches_relative_path_from_skill_root() {
let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs();
let scripts_dir = canonicalize_if_exists(&test_path_buf("/tmp/skill-test/scripts").abs());
let scripts_dir = canonicalize_if_exists(&EnvironmentPathRef::local(
test_path_buf("/tmp/skill-test/scripts").abs(),
));
let skill = test_skill_metadata(skill_doc_path);
let outcome = SkillLoadOutcome {
implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])),
@@ -93,7 +101,11 @@ fn skill_script_run_detection_matches_relative_path_from_skill_root() {
"scripts/fetch_comments.py".to_string(),
];
let found = detect_skill_script_run(&outcome, &tokens, &test_path_buf("/tmp/skill-test").abs());
let found = detect_skill_script_run(
&outcome,
&tokens,
&EnvironmentPathRef::local(test_path_buf("/tmp/skill-test").abs()),
);
assert_eq!(
found.map(|value| value.name),
@@ -104,7 +116,9 @@ fn skill_script_run_detection_matches_relative_path_from_skill_root() {
#[test]
fn skill_script_run_detection_matches_absolute_path_from_any_workdir() {
let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs();
let scripts_dir = canonicalize_if_exists(&test_path_buf("/tmp/skill-test/scripts").abs());
let scripts_dir = canonicalize_if_exists(&EnvironmentPathRef::local(
test_path_buf("/tmp/skill-test/scripts").abs(),
));
let skill = test_skill_metadata(skill_doc_path);
let outcome = SkillLoadOutcome {
implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])),
@@ -116,7 +130,11 @@ fn skill_script_run_detection_matches_absolute_path_from_any_workdir() {
test_path_display("/tmp/skill-test/scripts/fetch_comments.py"),
];
let found = detect_skill_script_run(&outcome, &tokens, &test_path_buf("/tmp/other").abs());
let found = detect_skill_script_run(
&outcome,
&tokens,
&EnvironmentPathRef::local(test_path_buf("/tmp/other").abs()),
);
assert_eq!(
found.map(|value| value.name),

View File

@@ -262,7 +262,8 @@ async fn skill_roots_with_home_dir(
// Assemble one precedence-ordered root list from the two authorities above, then dedupe before
// any reads happen so downstream loading stays oblivious to local-vs-selected-env routing.
let mut roots = env_paths
.iter()
.first()
.into_iter()
.flat_map(|env_path| repo_config_skill_roots(env_path, config_layer_stack))
.collect::<Vec<_>>();
roots.extend(local_skill_roots(
@@ -383,6 +384,7 @@ fn local_skill_roots(
}));
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
path: EnvironmentPathRef::new(Arc::clone(local_file_system), path),
environment_id: "local".to_string(),
scope: SkillScope::User,
plugin_id: None,
plugin_root: None,
@@ -395,9 +397,9 @@ fn repo_config_skill_roots(
env_path: &SkillEnvironment,
config_layer_stack: &ConfigLayerStack,
) -> Vec<SkillRoot> {
// Project config layers describe workspace-local `.codex/skills` directories. Their absolute
// paths only make sense inside the selected environment, so keep them bound to `env_path`
// rather than the optional local filesystem used for client-local system/user/plugin roots.
// Project config layers come from the one config stack for this turn/cwd. Bind them to the
// primary selected environment only; rebinding those same absolute folders into every
// secondary environment would attribute the primary cwd's `.codex/skills` to the wrong env.
config_layer_stack
.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,

View File

@@ -351,6 +351,49 @@ async fn skill_roots_bind_repo_and_local_roots_to_their_own_file_systems() -> an
Ok(())
}
#[tokio::test]
async fn skill_roots_bind_project_config_roots_to_primary_environment_only() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let project_root = codex_home.path().join("workspace");
fs::create_dir_all(project_root.join(".git"))?;
fs::create_dir_all(project_root.join(REPO_ROOT_CONFIG_DIR_NAME))?;
let cfg = make_config_for_cwd(&codex_home, project_root.clone()).await;
let primary_file_system: Arc<dyn ExecutorFileSystem> = Arc::new(LocalFileSystem::unsandboxed());
let secondary_file_system: Arc<dyn ExecutorFileSystem> =
Arc::new(LocalFileSystem::unsandboxed());
let roots = super::skill_roots(
&[
super::SkillEnvironment {
environment_id: "primary".to_string(),
path: EnvironmentPathRef::new(primary_file_system.clone(), cfg.cwd.clone()),
},
super::SkillEnvironment {
environment_id: "secondary".to_string(),
path: EnvironmentPathRef::new(secondary_file_system, cfg.cwd.clone()),
},
],
/*local_file_system*/ None,
&cfg.config_layer_stack,
Vec::new(),
Vec::new(),
)
.await;
let repo_roots = roots
.iter()
.filter(|root| root.scope == SkillScope::Repo)
.collect::<Vec<_>>();
assert_eq!(repo_roots.len(), 1);
assert_eq!(repo_roots[0].environment_id, "primary");
assert!(Arc::ptr_eq(
&repo_roots[0].path.file_system(),
&primary_file_system
));
Ok(())
}
#[tokio::test]
async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;

View File

@@ -344,7 +344,7 @@ fn config_skills_cache_key(
fn finalize_skill_outcome(
mut outcome: SkillLoadOutcome,
disabled_paths: HashSet<AbsolutePathBuf>,
disabled_paths: HashSet<EnvironmentPathRef>,
) -> SkillLoadOutcome {
outcome.disabled_paths = disabled_paths;
let (by_scripts_dir, by_doc_path) =

View File

@@ -387,7 +387,7 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
.abs();
assert_eq!(skill.path_to_skills_md, skill_path);
assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md));
assert!(outcome.disabled_paths.contains(&skill.source_path));
assert!(
!outcome
.allowed_skills_for_implicit_invocation()
@@ -665,10 +665,12 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill(
let skill_config_rules = skill_config_rules_from_stack(&stack);
assert_eq!(
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
HashSet::from([skill_path
.abs()
.canonicalize()
.expect("skill path should canonicalize")])
HashSet::from([EnvironmentPathRef::local(
skill_path
.abs()
.canonicalize()
.expect("skill path should canonicalize"),
)])
);
}
@@ -698,10 +700,12 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() {
let skill_config_rules = skill_config_rules_from_stack(&stack);
assert_eq!(
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
HashSet::from([skill_path
.abs()
.canonicalize()
.expect("skill path should canonicalize")])
HashSet::from([EnvironmentPathRef::local(
skill_path
.abs()
.canonicalize()
.expect("skill path should canonicalize"),
)])
);
}
@@ -740,6 +744,45 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
);
}
#[cfg_attr(windows, ignore)]
#[test]
fn disabled_paths_for_skills_disables_same_raw_path_in_each_environment() {
let tempdir = tempfile::tempdir().expect("tempdir");
let skill_path = write_demo_skill(&tempdir);
let mut devbox_skill = test_skill("demo-skill", skill_path.clone());
devbox_skill.environment_id = "devbox".to_string();
devbox_skill.source_path = EnvironmentPathRef::new(
Arc::new(codex_exec_server::LocalFileSystem::unsandboxed()),
devbox_skill.path_to_skills_md.clone(),
);
let local_skill = test_skill("demo-skill", skill_path.clone());
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
.expect("user config path should be absolute");
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file,
profile: None,
},
toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false))
.expect("user layer toml"),
);
let stack = ConfigLayerStack::new(
vec![user_layer],
Default::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
let skill_config_rules = skill_config_rules_from_stack(&stack);
assert_eq!(
resolve_disabled_skill_paths(
&[devbox_skill.clone(), local_skill.clone()],
&skill_config_rules
),
HashSet::from([devbox_skill.source_path, local_skill.source_path])
);
}
#[cfg_attr(windows, ignore)]
#[tokio::test]
async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() {

View File

@@ -2,17 +2,17 @@ use std::collections::HashMap;
use std::collections::HashSet;
use super::SkillMetadata;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_exec_server::EnvironmentPathRef;
/// Counts how often each skill name appears (exact and ASCII-lowercase), excluding disabled paths.
pub fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
disabled_paths: &HashSet<EnvironmentPathRef>,
) -> (HashMap<String, usize>, HashMap<String, usize>) {
let mut exact_counts: HashMap<String, usize> = HashMap::new();
let mut lower_counts: HashMap<String, usize> = HashMap::new();
for skill in skills {
if disabled_paths.contains(&skill.path_to_skills_md) {
if disabled_paths.contains(&skill.source_path) {
continue;
}
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;

View File

@@ -91,16 +91,16 @@ pub struct SkillError {
pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
pub disabled_paths: HashSet<AbsolutePathBuf>,
pub disabled_paths: HashSet<EnvironmentPathRef>,
pub(crate) skill_roots: Vec<EnvironmentPathRef>,
pub(crate) skill_root_by_path: Arc<HashMap<EnvironmentPathRef, EnvironmentPathRef>>,
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<EnvironmentPathRef, SkillMetadata>>,
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<EnvironmentPathRef, SkillMetadata>>,
}
impl SkillLoadOutcome {
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
!self.disabled_paths.contains(&skill.path_to_skills_md)
!self.disabled_paths.contains(&skill.source_path)
}
pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool {
@@ -120,6 +120,15 @@ impl SkillLoadOutcome {
.iter()
.map(|skill| (skill, self.is_skill_enabled(skill)))
}
pub fn has_multiple_environments(&self) -> bool {
self.skills
.iter()
.map(|skill| skill.environment_id.as_str())
.collect::<HashSet<_>>()
.len()
> 1
}
}
pub fn filter_skill_load_outcome_for_product(

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::SkillsManager;
use crate::config::ConfigBuilder;
use crate::skills_load_input_from_config;
use crate::skills_load_input;
use codex_config::ConfigLayerStackOrdering;
use codex_core_plugins::PluginsManager;
use codex_protocol::config_types::ServiceTier;
@@ -425,10 +425,12 @@ enabled = false
Arc::clone(&codex_exec_server::LOCAL_FS),
config.cwd.clone(),
);
let skills_input = skills_load_input_from_config(
let skills_input = skills_load_input(
&config,
codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
vec![codex_core_skills::loader::SkillEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
path: cwd,
}],
Some(Arc::clone(&codex_exec_server::LOCAL_FS)),
effective_skill_roots,
);

View File

@@ -95,7 +95,7 @@ pub(crate) use skills::default_skill_metadata_budget;
pub(crate) use skills::injection;
pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::skills_load_input_from_config;
pub(crate) use skills::skills_load_input;
mod stream_events_utils;
pub mod test_support;
mod unified_exec;

View File

@@ -36,7 +36,6 @@ use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::session_prefix::format_subagent_notification_message;
use crate::skills::SkillRenderSideEffects;
use crate::skills_load_input_from_config;
use crate::turn_metadata::TurnMetadataState;
use crate::turn_timing::now_unix_timestamp_ms;
use async_channel::Receiver;
@@ -2744,7 +2743,9 @@ impl Session {
let available_skills = build_available_skills(
&turn_context.turn_skills.outcome,
default_skill_metadata_budget(turn_context.model_info.context_window),
if turn_context.environments.turn_environments.len() > 1 {
if turn_context.environments.turn_environments.len() > 1
|| turn_context.turn_skills.outcome.has_multiple_environments()
{
crate::skills::render::SkillEnvironmentRender::Include
} else {
crate::skills::render::SkillEnvironmentRender::Omit

View File

@@ -3,6 +3,7 @@ use super::*;
use crate::config::ConstraintError;
use crate::goals::GoalRuntimeState;
use crate::skills::SkillError;
use crate::skills_load_input;
use crate::state::ActiveTurn;
use codex_protocol::SessionId;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
@@ -473,10 +474,12 @@ async fn warm_plugins_and_skills_for_session_init(
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(
let skills_input = skills_load_input(
config.as_ref(),
environment_id,
path_ref,
vec![codex_core_skills::loader::SkillEnvironment {
environment_id,
path: path_ref,
}],
local_file_system,
effective_skill_roots,
);

View File

@@ -8,6 +8,7 @@ use crate::context::TurnAborted;
use crate::function_tool::FunctionCallError;
use crate::shell::default_user_shell;
use crate::skills::SkillRenderSideEffects;
use crate::skills::render::SkillEnvironmentRender;
use crate::skills::render::SkillMetadataBudget;
use crate::test_support::models_manager_with_provider;
use crate::tools::format_exec_output_str;
@@ -4127,13 +4128,15 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
.services
.skills_manager
.skills_for_cwd(
&crate::skills_load_input_from_config(
&crate::skills_load_input(
&parent_config,
codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
crate::skills::EnvironmentPathRef::new(
Arc::clone(&skill_fs),
parent_config.cwd.clone(),
),
vec![codex_core_skills::loader::SkillEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
path: crate::skills::EnvironmentPathRef::new(
Arc::clone(&skill_fs),
parent_config.cwd.clone(),
),
}],
session
.services
.environment_manager
@@ -4708,10 +4711,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
environment.get_filesystem(),
per_turn_config.cwd.clone(),
);
let skills_input = crate::skills_load_input_from_config(
let skills_input = crate::skills_load_input(
&per_turn_config,
codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
vec![codex_core_skills::loader::SkillEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
path: cwd,
}],
services
.environment_manager
.try_local_environment()
@@ -6562,10 +6567,12 @@ where
environment.get_filesystem(),
per_turn_config.cwd.clone(),
);
let skills_input = crate::skills_load_input_from_config(
let skills_input = crate::skills_load_input(
&per_turn_config,
codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
vec![codex_core_skills::loader::SkillEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
path: cwd,
}],
services
.environment_manager
.try_local_environment()
@@ -7302,6 +7309,68 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget()
);
}
#[tokio::test]
async fn build_initial_context_qualifies_skills_only_when_skill_envs_are_ambiguous() {
let (session, mut turn_context) = make_session_and_context().await;
let make_skill = |name: &str, environment_id: &str| SkillMetadata {
name: name.to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md"))
.abs()
.clone(),
source_path: codex_exec_server::EnvironmentPathRef::local(
test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
),
environment_id: environment_id.to_string(),
scope: SkillScope::Repo,
plugin_id: None,
};
let mut single_env_outcome = SkillLoadOutcome::default();
single_env_outcome.skills = vec![make_skill("repo-skill", "local")];
turn_context.turn_skills = TurnSkillsContext::new(Arc::new(single_env_outcome));
let single_env_context = session.build_initial_context(&turn_context).await;
let single_env_texts = developer_input_texts(&single_env_context);
assert!(
single_env_texts
.iter()
.any(|text| text.contains("- repo-skill: desc (file: /tmp/repo-skill/SKILL.md)"))
);
assert!(single_env_texts.iter().all(|text| !text.contains("env:")));
let mut secondary_environment = turn_context.environments.turn_environments[0].clone();
secondary_environment.environment_id = "devbox".to_string();
turn_context
.environments
.turn_environments
.push(secondary_environment);
let multi_turn_env_context = session.build_initial_context(&turn_context).await;
let multi_turn_env_texts = developer_input_texts(&multi_turn_env_context);
assert!(multi_turn_env_texts.iter().any(|text| {
text.contains("- repo-skill: desc (env: local, file: /tmp/repo-skill/SKILL.md)")
}));
turn_context.environments.turn_environments.truncate(1);
let mut mixed_env_outcome = SkillLoadOutcome::default();
mixed_env_outcome.skills = vec![
make_skill("repo-skill", "devbox"),
make_skill("user-skill", "local"),
];
turn_context.turn_skills = TurnSkillsContext::new(Arc::new(mixed_env_outcome));
let mixed_env_context = session.build_initial_context(&turn_context).await;
let mixed_env_texts = developer_input_texts(&mixed_env_context);
assert!(mixed_env_texts.iter().any(|text| {
text.contains("- repo-skill: desc (env: devbox, file: /tmp/repo-skill/SKILL.md)")
}));
assert!(mixed_env_texts.iter().any(|text| {
text.contains("- user-skill: desc (env: local, file: /tmp/user-skill/SKILL.md)")
}));
}
#[test]
fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() {
let session_telemetry = test_session_telemetry_without_metadata();

View File

@@ -533,7 +533,6 @@ async fn build_skills_and_plugins(
warnings: skill_warnings,
} = build_skill_injections(
&mentioned_skills,
Some(skills_outcome),
Some(&turn_context.session_telemetry),
&sess.services.analytics_events_client,
tracking.clone(),

View File

@@ -706,7 +706,7 @@ impl Session {
.plugins_for_config(&plugins_input)
.await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = crate::skills::skills_load_input_from_environments(
let skills_input = crate::skills::skills_load_input(
&per_turn_config,
skill_env_paths,
local_file_system,

View File

@@ -36,26 +36,7 @@ pub use codex_core_skills::render;
pub use codex_core_skills::render::SkillRenderSideEffects;
pub use codex_core_skills::system;
pub(crate) fn skills_load_input_from_config(
config: &Config,
environment_id: String,
env_path: EnvironmentPathRef,
local_file_system: Option<Arc<dyn ExecutorFileSystem>>,
effective_skill_roots: Vec<PluginSkillRoot>,
) -> SkillsLoadInput {
SkillsLoadInput::new(
vec![codex_core_skills::loader::SkillEnvironment {
environment_id,
path: env_path,
}],
local_file_system,
effective_skill_roots,
config.config_layer_stack.clone(),
config.bundled_skills_enabled(),
)
}
pub(crate) fn skills_load_input_from_environments(
pub(crate) fn skills_load_input(
config: &Config,
env_paths: Vec<codex_core_skills::loader::SkillEnvironment>,
local_file_system: Option<Arc<dyn ExecutorFileSystem>>,
@@ -76,13 +57,20 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation(
command: &str,
workdir: &AbsolutePathBuf,
) {
let Some(turn_environment) = turn_context.environments.primary() else {
return;
};
let Some(candidate) = detect_implicit_skill_invocation_for_command(
turn_context.turn_skills.outcome.as_ref(),
command,
workdir,
&EnvironmentPathRef::new(
turn_environment.environment.get_filesystem(),
workdir.clone(),
),
) else {
return;
};
let environment_id = candidate.environment_id.clone();
let invocation = SkillInvocation {
skill_name: candidate.name,
skill_scope: candidate.scope,
@@ -98,7 +86,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation(
};
let skill_path = invocation.skill_path.to_string_lossy();
let skill_name = invocation.skill_name.clone();
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
let seen_key = format!("{environment_id}:{skill_scope}:{skill_path}:{skill_name}");
let inserted = {
let mut seen_skills = turn_context
.turn_skills