gitdataai/libs/agent/perception/passive.rs
ZhenYi 5c1b14c26a refactor(perception): simplify active/auto detection and deduplication
- Remove activation threshold logic from PassiveSkillAwareness
- Add SkillActivation enum with Priority/Keyword/Vector/Auto variants
- Add deduplication via SkillContext.dedupe_key() using rank ordering
- Simplify ActiveSkillAwareness with cleaner regex-based detection
2026-05-17 17:31:50 +08:00

120 lines
4.1 KiB
Rust

//! Passive skill awareness: tool-call and event driven activation.
use super::{SkillActivation, SkillContext, SkillEntry, ToolCallEvent, normalize_skill_key};
#[derive(Debug, Clone, Default)]
pub struct PassiveSkillAwareness;
impl PassiveSkillAwareness {
pub fn new() -> Self {
Self
}
pub fn detect(&self, event: &ToolCallEvent, skills: &[SkillEntry]) -> Option<SkillContext> {
let tool_name = event.tool_name.to_lowercase();
let args = event.arguments.to_lowercase();
if let Some(skill) = Self::match_tool_category(&tool_name, skills) {
return Some(Self::context_from_skill(skill, "tool category"));
}
for skill in skills {
let slug = normalize_skill_key(&skill.slug);
let name = skill.name.to_lowercase();
if Self::slug_in_text(&tool_name, &slug) {
return Some(Self::context_from_skill(skill, "tool invocation"));
}
if Self::slug_in_text(&args, &slug) || Self::keyword_match(&args, &name) {
return Some(Self::context_from_skill(skill, "tool arguments"));
}
}
None
}
pub fn detect_from_text(&self, text: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
let text_lower = text.to_lowercase();
skills.iter().find_map(|skill| {
let slug = normalize_skill_key(&skill.slug);
let name = skill.name.to_lowercase();
(Self::slug_in_text(&text_lower, &slug) || Self::keyword_match(&text_lower, &name))
.then(|| Self::context_from_skill(skill, "observation match"))
})
}
fn match_tool_category<'a>(
tool_name: &str,
skills: &'a [SkillEntry],
) -> Option<&'a SkillEntry> {
const CATEGORY_MAP: &[(&str, &[&str])] = &[
("git_", &["git"]),
("repo_", &["repo", "repository"]),
("project_", &["repo", "repository", "project"]),
("issue_", &["issue", "triage"]),
("list_issues", &["issue", "triage"]),
("create_issue", &["issue"]),
("update_issue", &["issue"]),
("pr_", &["pr", "pull", "pull-request"]),
("pull_request_", &["pr", "pull", "pull-request"]),
("code_review", &["code-review", "review"]),
("security_", &["security", "review"]),
("test_", &["test", "testing"]),
("read_", &["file", "reader"]),
("git_file", &["file", "reader"]),
("curl", &["http", "api"]),
("project_curl", &["http", "api"]),
];
for (prefix, categories) in CATEGORY_MAP {
if tool_name.starts_with(prefix) {
if let Some(skill) = skills.iter().find(|skill| {
let slug = normalize_skill_key(&skill.slug);
let name = skill.name.to_lowercase();
categories
.iter()
.any(|category| slug.contains(category) || name.contains(category))
}) {
return Some(skill);
}
}
}
None
}
fn slug_in_text(text: &str, slug: &str) -> bool {
text.contains(slug)
|| slug
.split(['/', '-'])
.filter(|seg| seg.len() >= 3)
.any(|seg| text.contains(seg))
}
fn keyword_match(text: &str, name: &str) -> bool {
let significant = name
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 3)
.collect::<Vec<_>>();
if significant.len() >= 2 {
significant.iter().all(|w| text.contains(*w))
} else {
significant.first().is_some_and(|w| text.contains(w))
}
}
fn context_from_skill(skill: &SkillEntry, trigger: &str) -> SkillContext {
SkillContext::new(
skill,
SkillActivation::Passive,
Some(trigger),
format!(
"# {} (passive: {})\n\n{}",
skill.name, trigger, skill.content
),
None,
)
}
}