From 7fb2f4799907893691c57dddb8522b9415dbe6ff Mon Sep 17 00:00:00 2001 From: Nick Stebbings <47646783+nick-stebbings@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:23:41 +1300 Subject: [PATCH] feat(skills): exclude_keywords veto in skill activation scoring (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): exclude_keywords veto in skill activation scoring Add exclude_keywords field to ActivationCriteria. If any exclude keyword is present in the user message, the skill scores 0 regardless of keyword or pattern matches — prevents cross-skill interference. Behaviour: exclude_keywords is a hard veto. Even an exact skill name match gets vetoed if an exclude keyword is also present. This is intentional; partial exclusion (score reduction) would create unpredictable interference behaviour. Example use case: a writing skill with keywords ["write", "draft"] and exclude_keywords ["route", "redirect"] will not activate on messages like "don't route this to the writing agent". Changes: - ActivationCriteria: new exclude_keywords field (serde default) - LoadedSkill: new lowercased_exclude_keywords (preprocessed at load) - selector.rs: early-return 0 in score_skill() on veto match - registry.rs: populate lowercased_exclude_keywords during loading - Test helpers updated across mod.rs, selector.rs, attenuation.rs Co-Authored-By: Claude Opus 4.6 * Fix review feedback: enforce limits on exclude_keywords, extract helper, use any() - Add exclude_keywords to enforce_limits() with same min-length and cap rules as keywords — prevents empty string always-match and unbounded lists - Extract to_lowercase_vec() helper to deduplicate three identical blocks - Use idiomatic any() iterator instead of for loop in score_skill veto check Co-Authored-By: Claude Opus 4.6 * test(skills): add exclude_keywords veto tests Adds 4 tests for the exclude_keywords veto behavior as requested in review: 1. test_exclude_keyword_vetos_match — skill scores 0 when exclude keyword present 2. test_exclude_keyword_absent_does_not_block — skill activates normally without it 3. test_exclude_keyword_veto_wins_over_positive_match — veto wins even with multiple keyword hits 4. test_exclude_keyword_case_insensitive — veto fires regardless of message case Also adds make_skill_with_excludes() test helper to avoid repeating the LoadedSkill construction boilerplate in each test. Note on substring matching: exclude_keywords uses message_lower.contains(excl) (substring match), consistent with the existing positive keyword scoring path. This means "red" would veto "redirect". This is documented behaviour — if word-boundary semantics are needed, that's a follow-up change. Co-Authored-By: Claude Sonnet 4.6 * style: run cargo fmt on selector.rs Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/skills/attenuation.rs | 1 + src/skills/mod.rs | 11 ++++ src/skills/registry.rs | 20 +++---- src/skills/selector.rs | 118 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 12 deletions(-) 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" + ); + } }