feat(skills): exclude_keywords veto in skill activation scoring (#688)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* style: run cargo fmt on selector.rs

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Nick Stebbings
2026-03-08 20:23:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 02f85a8ad5
commit 7fb2f47999
4 changed files with 138 additions and 12 deletions
+1
View File
@@ -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![],
}
}
+11
View File
@@ -98,6 +98,10 @@ pub struct ActivationCriteria {
/// Capped at `MAX_KEYWORDS_PER_SKILL` during loading.
#[serde(default)]
pub keywords: Vec<String>,
/// 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<String>,
/// 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<String>,
/// Pre-computed lowercased exclude keywords for veto scoring.
/// Derived from `manifest.activation.exclude_keywords` at load time.
pub lowercased_exclude_keywords: Vec<String>,
/// 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<String>,
@@ -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");
+8 -12
View File
@@ -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<String> {
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,
};
+118
View File
@@ -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<String> = 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"
);
}
}