mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
02f85a8ad5
commit
7fb2f47999
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user