//! Passive skill awareness — reactive skill retrieval triggered by events. //! //! The agent passively activates a skill when its slug or name appears in: //! //! - Tool call arguments (e.g., a tool is called with a repository name that matches a "git" skill) //! - Tool call results / observations (e.g., a linter reports issues matching a "code-review" skill) //! - System events emitted during the agent loop (e.g., "PR opened" → "pr-review" skill) //! //! This is lower-priority than active but higher than auto — it's triggered by //! specific events rather than ambient relevance scoring. use super::{SkillContext, SkillEntry, ToolCallEvent}; /// Passive skill awareness triggered by tool-call and event context. #[derive(Debug, Clone, Default)] pub struct PassiveSkillAwareness; impl PassiveSkillAwareness { pub fn new() -> Self { Self } /// Detect skill activation from tool-call events. /// /// The agent can passively "wake up" a skill when: /// - A tool call's name or arguments contain a skill slug or keyword /// - A tool call result mentions a skill name /// /// This is primarily driven by tool naming conventions and argument patterns. /// For example, a tool named `git_diff` might passively activate a `git` skill. pub fn detect(&self, event: &ToolCallEvent, skills: &[SkillEntry]) -> Option { let tool_name = event.tool_name.to_lowercase(); let args = event.arguments.to_lowercase(); for skill in skills { let slug = skill.slug.to_lowercase(); let name = skill.name.to_lowercase(); // Trigger 1: Tool name contains skill slug segment. // e.g., tool "git_blame" → skill "git/*" activates if Self::slug_in_text(&tool_name, &slug) { return Some(Self::context_from_skill(skill, "tool invocation")); } // Trigger 2: Tool arguments contain skill slug or name keywords. // e.g., arguments mention "security" → "security/scan" skill if Self::slug_in_text(&args, &slug) || Self::keyword_match(&args, &name) { return Some(Self::context_from_skill(skill, "tool arguments")); } // Trigger 3: Common tool prefixes that map to skill categories. if let Some(cat_skill) = Self::match_tool_category(&tool_name, skills) { return Some(cat_skill); } } None } /// Detect skill activation from a raw text observation (e.g., tool result text). pub fn detect_from_text(&self, text: &str, skills: &[SkillEntry]) -> Option { let text_lower = text.to_lowercase(); for skill in skills { let slug = skill.slug.to_lowercase(); let name = skill.name.to_lowercase(); if Self::slug_in_text(&text_lower, &slug) || Self::keyword_match(&text_lower, &name) { return Some(Self::context_from_skill(skill, "observation match")); } } None } /// Match common tool name prefixes to skill categories. fn match_tool_category(tool_name: &str, skills: &[SkillEntry]) -> Option { let category_map = [ ("git_", "git"), ("repo_", "repo"), ("issue_", "issue"), ("pr_", "pull_request"), ("pull_request_", "pull_request"), ("code_review", "code-review"), ("security_scan", "security"), ("linter", "linter"), ("test_", "testing"), ("deploy_", "deployment"), ("docker_", "docker"), ("k8s_", "kubernetes"), ("db_", "database"), ("sql_", "database"), ]; for (prefix, category) in category_map { if tool_name.starts_with(prefix) { if let Some(skill) = skills.iter().find(|s| { s.slug.to_lowercase().contains(category) || s.name.to_lowercase().contains(category) }) { return Some(Self::context_from_skill(skill, "tool category match")); } } } None } /// True if the slug (or a significant segment of it) appears in the text. fn slug_in_text(text: &str, slug: &str) -> bool { text.contains(slug) || slug .split('/') .filter(|seg| seg.len() >= 3) .any(|seg| text.contains(seg)) } /// Match skill name keywords against the text (handles multi-word names). fn keyword_match(text: &str, name: &str) -> bool { // For multi-word names, require all significant words to appear. let significant: Vec<_> = name .split(|c: char| !c.is_alphanumeric()) .filter(|w| w.len() >= 3) .collect(); if significant.len() >= 2 { significant.iter().all(|w| text.contains(*w)) } else { significant.first().map_or(false, |w| text.contains(w)) } } fn context_from_skill(skill: &SkillEntry, trigger: &str) -> SkillContext { SkillContext { label: format!("Passive skill: {} ({})", skill.name, trigger), content: format!( "# {} (passive — {})\n\n{}", skill.name, trigger, skill.content ), } } }