gitdataai/libs/agent/perception/passive.rs
2026-04-14 19:02:01 +08:00

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
),
}
}
}