diff --git a/src/skills/attenuation.rs b/src/skills/attenuation.rs index 87b743ba..f0683f82 100644 --- a/src/skills/attenuation.rs +++ b/src/skills/attenuation.rs @@ -142,6 +142,7 @@ mod tests { content_hash: "sha256:000".to_string(), compiled_patterns: vec![], lowercased_keywords: vec![], + lowercased_exclude_keywords: vec![], lowercased_tags: vec![], } } diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 87e449c2..f81bd535 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -98,6 +98,10 @@ pub struct ActivationCriteria { /// Capped at `MAX_KEYWORDS_PER_SKILL` during loading. #[serde(default)] pub keywords: Vec, + /// Keywords that veto this skill — if any match, score is 0 regardless of + /// keyword/pattern matches. Prevents cross-skill interference. + #[serde(default)] + pub exclude_keywords: Vec, /// Regex patterns for more complex matching. /// Capped at `MAX_PATTERNS_PER_SKILL` during loading. #[serde(default)] @@ -118,6 +122,9 @@ impl ActivationCriteria { pub fn enforce_limits(&mut self) { self.keywords.retain(|k| k.len() >= MIN_KEYWORD_TAG_LENGTH); self.keywords.truncate(MAX_KEYWORDS_PER_SKILL); + self.exclude_keywords + .retain(|k| k.len() >= MIN_KEYWORD_TAG_LENGTH); + self.exclude_keywords.truncate(MAX_KEYWORDS_PER_SKILL); self.patterns.truncate(MAX_PATTERNS_PER_SKILL); self.tags.retain(|t| t.len() >= MIN_KEYWORD_TAG_LENGTH); self.tags.truncate(MAX_TAGS_PER_SKILL); @@ -199,6 +206,9 @@ pub struct LoadedSkill { /// Pre-computed lowercased keywords for scoring (avoids per-message allocation). /// Derived from `manifest.activation.keywords` at load time — do not mutate independently. pub lowercased_keywords: Vec, + /// Pre-computed lowercased exclude keywords for veto scoring. + /// Derived from `manifest.activation.exclude_keywords` at load time. + pub lowercased_exclude_keywords: Vec, /// Pre-computed lowercased tags for scoring (avoids per-message allocation). /// Derived from `manifest.activation.tags` at load time — do not mutate independently. pub lowercased_tags: Vec, @@ -513,6 +523,7 @@ metadata: content_hash: "sha256:000".to_string(), compiled_patterns: vec![], lowercased_keywords: vec![], + lowercased_exclude_keywords: vec![], lowercased_tags: vec![], }; assert_eq!(skill.name(), "test"); diff --git a/src/skills/registry.rs b/src/skills/registry.rs index c731da18..6f881f77 100644 --- a/src/skills/registry.rs +++ b/src/skills/registry.rs @@ -24,6 +24,10 @@ use crate::skills::{ /// Prevents resource exhaustion from a directory with thousands of entries. const MAX_DISCOVERED_SKILLS: usize = 100; +fn to_lowercase_vec(items: &[String]) -> Vec { + items.iter().map(|s| s.to_lowercase()).collect() +} + /// Error type for skill registry operations. #[derive(Debug, thiserror::Error)] pub enum SkillRegistryError { @@ -582,18 +586,9 @@ async fn load_and_validate_skill( let compiled_patterns = LoadedSkill::compile_patterns(&manifest.activation.patterns); // Pre-compute lowercased keywords and tags for efficient scoring - let lowercased_keywords = manifest - .activation - .keywords - .iter() - .map(|k| k.to_lowercase()) - .collect(); - let lowercased_tags = manifest - .activation - .tags - .iter() - .map(|t| t.to_lowercase()) - .collect(); + let lowercased_keywords = to_lowercase_vec(&manifest.activation.keywords); + let lowercased_exclude_keywords = to_lowercase_vec(&manifest.activation.exclude_keywords); + let lowercased_tags = to_lowercase_vec(&manifest.activation.tags); let name = manifest.name.clone(); let skill = LoadedSkill { @@ -604,6 +599,7 @@ async fn load_and_validate_skill( content_hash, compiled_patterns, lowercased_keywords, + lowercased_exclude_keywords, lowercased_tags, }; diff --git a/src/skills/selector.rs b/src/skills/selector.rs index f9a78aa9..f1de2aaa 100644 --- a/src/skills/selector.rs +++ b/src/skills/selector.rs @@ -99,6 +99,15 @@ pub fn prefilter_skills<'a>( /// Score a skill against a user message. fn score_skill(skill: &LoadedSkill, message_lower: &str, message_original: &str) -> u32 { + // Exclusion veto: if any exclude_keyword is present in the message, score 0 + if skill + .lowercased_exclude_keywords + .iter() + .any(|excl| message_lower.contains(excl.as_str())) + { + return 0; + } + let mut score: u32 = 0; // Keyword scoring with cap to prevent gaming via keyword stuffing @@ -158,6 +167,7 @@ mod tests { description: format!("{} skill", name), activation: ActivationCriteria { keywords: kw_vec, + exclude_keywords: vec![], patterns: pattern_strings, tags: tag_vec, max_context_tokens: 1000, @@ -170,6 +180,7 @@ mod tests { content_hash: "sha256:000".to_string(), compiled_patterns: compiled, lowercased_keywords, + lowercased_exclude_keywords: vec![], lowercased_tags, } } @@ -368,4 +379,111 @@ mod tests { let result = prefilter_skills("test", &skills, 5, 1); assert_eq!(result.len(), 1); } + + fn make_skill_with_excludes( + name: &str, + keywords: &[&str], + exclude_keywords: &[&str], + tags: &[&str], + patterns: &[&str], + ) -> LoadedSkill { + let mut skill = make_skill(name, keywords, tags, patterns); + let excl_vec: Vec = exclude_keywords.iter().map(|s| s.to_string()).collect(); + skill.lowercased_exclude_keywords = excl_vec.iter().map(|k| k.to_lowercase()).collect(); + skill.manifest.activation.exclude_keywords = excl_vec; + skill + } + + // --- exclude_keywords tests --- + + #[test] + fn test_exclude_keyword_vetos_match() { + // Skill matches on "write" but exclude_keywords: ["route"] — message contains "route" + // so the skill should score 0 and be excluded. + let skills = vec![make_skill_with_excludes( + "writer", + &["write"], + &["route"], + &[], + &[], + )]; + let result = prefilter_skills( + "route this write request to another agent", + &skills, + 3, + MAX_SKILL_CONTEXT_TOKENS, + ); + assert!( + result.is_empty(), + "skill with matching exclude_keyword should score 0" + ); + } + + #[test] + fn test_exclude_keyword_absent_does_not_block() { + // Same skill, message does NOT contain the exclude keyword — should activate normally. + let skills = vec![make_skill_with_excludes( + "writer", + &["write"], + &["route"], + &[], + &[], + )]; + let result = prefilter_skills( + "help me write an email", + &skills, + 3, + MAX_SKILL_CONTEXT_TOKENS, + ); + assert_eq!( + result.len(), + 1, + "skill should activate when no exclude_keyword is present" + ); + } + + #[test] + fn test_exclude_keyword_veto_wins_over_positive_match() { + // Both a keyword match AND an exclude_keyword match are present. + // The veto must win regardless of how high the positive score is. + let skills = vec![make_skill_with_excludes( + "writer", + &["write", "draft", "compose"], + &["redirect"], + &[], + &[], + )]; + let result = prefilter_skills( + "write and draft and compose — but redirect this somewhere else", + &skills, + 3, + MAX_SKILL_CONTEXT_TOKENS, + ); + assert!( + result.is_empty(), + "exclude_keyword veto must win even when multiple positive keywords match" + ); + } + + #[test] + fn test_exclude_keyword_case_insensitive() { + // exclude_keywords are pre-lowercased; the veto must fire regardless of case in the message. + let skills = vec![make_skill_with_excludes( + "writer", + &["write"], + &["Route"], + &[], + &[], + )]; + let result = prefilter_skills( + "please ROUTE this write request", + &skills, + 3, + MAX_SKILL_CONTEXT_TOKENS, + ); + assert!( + result.is_empty(), + "exclude_keyword veto should be case-insensitive" + ); + } }