use crate::skills::LoadedSkill;
pub const MAX_SKILL_CONTEXT_TOKENS: usize = 4000;
const MAX_KEYWORD_SCORE: u32 = 30;
const MAX_TAG_SCORE: u32 = 15;
const MAX_REGEX_SCORE: u32 = 40;
#[derive(Debug)]
pub struct ScoredSkill<'a> {
pub skill: &'a LoadedSkill,
pub score: u32,
}
pub fn prefilter_skills<'a>(
message: &str,
available_skills: &'a [LoadedSkill],
max_candidates: usize,
max_context_tokens: usize,
) -> Vec<&'a LoadedSkill> {
if available_skills.is_empty() || message.is_empty() {
return vec![];
}
let message_lower = message.to_lowercase();
let mut scored: Vec<ScoredSkill<'a>> = available_skills
.iter()
.filter_map(|skill| {
let score = score_skill(skill, &message_lower, message);
if score > 0 {
Some(ScoredSkill { skill, score })
} else {
None
}
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.score));
let mut result = Vec::new();
let mut budget_remaining = max_context_tokens;
for entry in scored {
if result.len() >= max_candidates {
break;
}
let declared_tokens = entry.skill.manifest.activation.max_context_tokens;
let approx_tokens = (entry.skill.prompt_content.len() as f64 * 0.25) as usize;
let raw_cost = if approx_tokens > declared_tokens * 2 {
tracing::warn!(
"Skill '{}' declares max_context_tokens={} but prompt is ~{} tokens; using actual estimate",
entry.skill.name(),
declared_tokens,
approx_tokens,
);
approx_tokens
} else {
declared_tokens
};
let token_cost = raw_cost.max(1);
if token_cost <= budget_remaining {
budget_remaining -= token_cost;
result.push(entry.skill);
}
}
result
}
fn score_skill(skill: &LoadedSkill, message_lower: &str, message_original: &str) -> u32 {
if skill
.lowercased_exclude_keywords
.iter()
.any(|excl| message_lower.contains(excl.as_str()))
{
return 0;
}
let mut score: u32 = 0;
let mut keyword_score: u32 = 0;
for kw_lower in &skill.lowercased_keywords {
if message_lower
.split_whitespace()
.any(|word| word.trim_matches(|c: char| !c.is_alphanumeric()) == kw_lower.as_str())
{
keyword_score += 10;
} else if message_lower.contains(kw_lower.as_str()) {
keyword_score += 5;
}
}
score += keyword_score.min(MAX_KEYWORD_SCORE);
let mut tag_score: u32 = 0;
for tag_lower in &skill.lowercased_tags {
if message_lower.contains(tag_lower.as_str()) {
tag_score += 3;
}
}
score += tag_score.min(MAX_TAG_SCORE);
let mut regex_score: u32 = 0;
for re in &skill.compiled_patterns {
if re.is_match(message_original) {
regex_score += 20;
}
}
score += regex_score.min(MAX_REGEX_SCORE);
score
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::{ActivationCriteria, LoadedSkill, SkillManifest, SkillSource, SkillTrust};
use std::path::PathBuf;
fn make_skill(name: &str, keywords: &[&str], tags: &[&str], patterns: &[&str]) -> LoadedSkill {
let pattern_strings: Vec<String> = patterns.iter().map(|s| s.to_string()).collect();
let compiled = LoadedSkill::compile_patterns(&pattern_strings);
let kw_vec: Vec<String> = keywords.iter().map(|s| s.to_string()).collect();
let tag_vec: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
let lowercased_keywords = kw_vec.iter().map(|k| k.to_lowercase()).collect();
let lowercased_tags = tag_vec.iter().map(|t| t.to_lowercase()).collect();
LoadedSkill {
manifest: SkillManifest {
name: name.to_string(),
version: "1.0.0".to_string(),
description: format!("{} skill", name),
activation: ActivationCriteria {
keywords: kw_vec,
exclude_keywords: vec![],
patterns: pattern_strings,
tags: tag_vec,
max_context_tokens: 1000,
},
metadata: None,
},
prompt_content: "Test prompt".to_string(),
trust: SkillTrust::Trusted,
source: SkillSource::User(PathBuf::from("/tmp/test")),
content_hash: "sha256:000".to_string(),
compiled_patterns: compiled,
lowercased_keywords,
lowercased_exclude_keywords: vec![],
lowercased_tags,
}
}
#[test]
fn test_empty_message_returns_nothing() {
let skills = vec![make_skill("test", &["write"], &[], &[])];
let result = prefilter_skills("", &skills, 3, MAX_SKILL_CONTEXT_TOKENS);
assert!(result.is_empty());
}
#[test]
fn test_no_matching_skills() {
let skills = vec![make_skill("cooking", &["recipe", "cook", "bake"], &[], &[])];
let result = prefilter_skills(
"Help me write an email",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert!(result.is_empty());
}
#[test]
fn test_keyword_exact_match() {
let skills = vec![make_skill("writing", &["write", "edit"], &[], &[])];
let result = prefilter_skills(
"Please write an email",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name(), "writing");
}
#[test]
fn test_keyword_substring_match() {
let skills = vec![make_skill("writing", &["writing"], &[], &[])];
let result = prefilter_skills(
"I need help with rewriting this text",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_tag_match() {
let skills = vec![make_skill("writing", &[], &["prose", "email"], &[])];
let result = prefilter_skills(
"Draft an email for me",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_regex_pattern_match() {
let skills = vec![make_skill(
"writing",
&[],
&[],
&[r"(?i)\b(write|draft)\b.*\b(email|letter)\b"],
)];
let result = prefilter_skills(
"Please draft an email to my boss",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_scoring_priority() {
let skills = vec![
make_skill("cooking", &["cook"], &[], &[]),
make_skill(
"writing",
&["write", "draft"],
&["email"],
&[r"(?i)\b(write|draft)\b.*\bemail\b"],
),
];
let result = prefilter_skills(
"Write and draft an email",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name(), "writing");
}
#[test]
fn test_max_candidates_limit() {
let skills = vec![
make_skill("a", &["test"], &[], &[]),
make_skill("b", &["test"], &[], &[]),
make_skill("c", &["test"], &[], &[]),
];
let result = prefilter_skills("test", &skills, 2, MAX_SKILL_CONTEXT_TOKENS);
assert_eq!(result.len(), 2);
}
#[test]
fn test_context_budget_limit() {
let mut skill = make_skill("big", &["test"], &[], &[]);
skill.manifest.activation.max_context_tokens = 3000;
let mut skill2 = make_skill("also_big", &["test"], &[], &[]);
skill2.manifest.activation.max_context_tokens = 3000;
let skills = vec![skill, skill2];
let result = prefilter_skills("test", &skills, 5, 4000);
assert_eq!(result.len(), 1);
}
#[test]
fn test_invalid_regex_handled_gracefully() {
let skills = vec![make_skill("bad", &["test"], &[], &["[invalid regex"])];
let result = prefilter_skills("test", &skills, 3, MAX_SKILL_CONTEXT_TOKENS);
assert_eq!(result.len(), 1);
}
#[test]
fn test_keyword_score_capped() {
let many_keywords: Vec<&str> = vec![
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p",
];
let skill = make_skill("spammer", &many_keywords, &[], &[]);
let skills = vec![skill];
let result = prefilter_skills(
"a b c d e f g h i j k l m n o p",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_tag_score_capped() {
let many_tags: Vec<&str> = vec![
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
];
let skill = make_skill("tag-spammer", &[], &many_tags, &[]);
let skills = vec![skill];
let result = prefilter_skills(
"alpha bravo charlie delta echo foxtrot golf hotel",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_regex_score_capped() {
let skill = make_skill(
"regex-spammer",
&[],
&[],
&[
r"(?i)\bwrite\b",
r"(?i)\bdraft\b",
r"(?i)\bedit\b",
r"(?i)\bcompose\b",
r"(?i)\bauthor\b",
],
);
let skills = vec![skill];
let result = prefilter_skills(
"write draft edit compose author",
&skills,
3,
MAX_SKILL_CONTEXT_TOKENS,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_zero_context_tokens_still_costs_budget() {
let mut skill = make_skill("free", &["test"], &[], &[]);
skill.manifest.activation.max_context_tokens = 0;
skill.prompt_content = String::new();
let mut skill2 = make_skill("also_free", &["test"], &[], &[]);
skill2.manifest.activation.max_context_tokens = 0;
skill2.prompt_content = String::new();
let skills = vec![skill, skill2];
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
}
#[test]
fn test_exclude_keyword_vetos_match() {
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() {
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() {
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() {
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"
);
}
}