145 lines
5.4 KiB
Rust
145 lines
5.4 KiB
Rust
//! 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<SkillContext> {
|
|
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<SkillContext> {
|
|
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<SkillContext> {
|
|
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
|
|
),
|
|
}
|
|
}
|
|
}
|