- 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
191 lines
5.6 KiB
Rust
191 lines
5.6 KiB
Rust
//! Skill perception system for the AI agent.
|
|
//!
|
|
//! Skills are injected through three modes:
|
|
//! - Active: explicit user invocation, highest priority.
|
|
//! - Passive: tool-call or event driven activation.
|
|
//! - Auto: ambient keyword relevance.
|
|
//!
|
|
//! Vector search is merged by the message builder as a semantic auto signal.
|
|
|
|
pub mod active;
|
|
pub mod auto;
|
|
pub mod passive;
|
|
pub mod vector;
|
|
|
|
pub use active::ActiveSkillAwareness;
|
|
pub use auto::AutoSkillAwareness;
|
|
pub use passive::PassiveSkillAwareness;
|
|
pub use vector::{VectorActiveAwareness, VectorPassiveAwareness};
|
|
|
|
use crate::client::ChatRequestMessage;
|
|
use std::collections::HashSet;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum SkillActivation {
|
|
Active,
|
|
Passive,
|
|
Vector,
|
|
Auto,
|
|
}
|
|
|
|
impl SkillActivation {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
SkillActivation::Active => "Active",
|
|
SkillActivation::Passive => "Passive",
|
|
SkillActivation::Vector => "Vector",
|
|
SkillActivation::Auto => "Auto",
|
|
}
|
|
}
|
|
|
|
pub fn rank(self) -> u8 {
|
|
match self {
|
|
SkillActivation::Active => 0,
|
|
SkillActivation::Passive => 1,
|
|
SkillActivation::Vector => 2,
|
|
SkillActivation::Auto => 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A chunk of skill context ready to be injected into the message list.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SkillContext {
|
|
/// Stable skill identifier used for de-duplication across trigger sources.
|
|
pub slug: String,
|
|
/// Human-readable label shown to the AI, e.g. "Active skill: code-review"
|
|
pub label: String,
|
|
/// The actual skill content to inject.
|
|
pub content: String,
|
|
/// How this skill was selected.
|
|
pub activation: SkillActivation,
|
|
/// Optional relevance score. Active/passive matches use `None`.
|
|
pub score: Option<f32>,
|
|
}
|
|
|
|
/// Converts skill context into a system message for injection.
|
|
impl SkillContext {
|
|
pub fn new(
|
|
skill: &SkillEntry,
|
|
activation: SkillActivation,
|
|
reason: Option<&str>,
|
|
content: String,
|
|
score: Option<f32>,
|
|
) -> Self {
|
|
let label = match reason {
|
|
Some(reason) => format!("{} skill: {} ({})", activation.label(), skill.name, reason),
|
|
None => format!("{} skill: {}", activation.label(), skill.name),
|
|
};
|
|
Self {
|
|
slug: skill.slug.clone(),
|
|
label,
|
|
content,
|
|
activation,
|
|
score,
|
|
}
|
|
}
|
|
|
|
pub fn dedupe_key(&self) -> String {
|
|
if !self.slug.trim().is_empty() {
|
|
return normalize_skill_key(&self.slug);
|
|
}
|
|
normalize_skill_key(&self.label)
|
|
}
|
|
|
|
pub fn to_system_message(self) -> ChatRequestMessage {
|
|
ChatRequestMessage::system(format!("[{}]\n{}", self.label, self.content))
|
|
}
|
|
}
|
|
|
|
pub fn normalize_skill_key(value: &str) -> String {
|
|
value
|
|
.trim()
|
|
.to_lowercase()
|
|
.replace(['_', ' '], "-")
|
|
.chars()
|
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '/')
|
|
.collect()
|
|
}
|
|
|
|
/// Unified perception service combining all three modes.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PerceptionService {
|
|
pub auto: AutoSkillAwareness,
|
|
pub active: ActiveSkillAwareness,
|
|
pub passive: PassiveSkillAwareness,
|
|
}
|
|
|
|
impl Default for PerceptionService {
|
|
fn default() -> Self {
|
|
Self {
|
|
auto: AutoSkillAwareness::default(),
|
|
active: ActiveSkillAwareness::default(),
|
|
passive: PassiveSkillAwareness::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PerceptionService {
|
|
/// Inject relevant skill context into the message list based on current conversation state.
|
|
///
|
|
/// - **auto**: Scans the current input and conversation history for skill-relevant keywords
|
|
/// and injects matching skills that are enabled.
|
|
/// - **active**: Checks if the user explicitly invoked a skill by slug (e.g. "用 code-review")
|
|
/// and injects it.
|
|
/// - **passive**: Checks if any tool-call events or prior observations mention a skill
|
|
/// slug and injects the matching skill.
|
|
///
|
|
/// Returns a list of system messages to prepend to the conversation.
|
|
pub async fn inject_skills(
|
|
&self,
|
|
input: &str,
|
|
history: &[String],
|
|
tool_calls: &[ToolCallEvent],
|
|
enabled_skills: &[SkillEntry],
|
|
) -> Vec<SkillContext> {
|
|
let mut results = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
|
|
// Active: explicit skill invocation (highest priority)
|
|
if let Some(skill) = self.active.detect(input, enabled_skills) {
|
|
seen.insert(skill.dedupe_key());
|
|
results.push(skill);
|
|
}
|
|
|
|
// Passive: triggered by tool-call events
|
|
for tc in tool_calls {
|
|
if let Some(skill) = self.passive.detect(tc, enabled_skills) {
|
|
if seen.insert(skill.dedupe_key()) {
|
|
results.push(skill);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto: keyword-based relevance matching
|
|
let auto_results = self.auto.detect(input, history, enabled_skills).await;
|
|
for skill in auto_results {
|
|
if seen.insert(skill.dedupe_key()) {
|
|
results.push(skill);
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
}
|
|
|
|
/// A tool-call event used for passive skill detection.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolCallEvent {
|
|
pub tool_name: String,
|
|
pub arguments: String,
|
|
}
|
|
|
|
/// A skill entry from the database, used for matching.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SkillEntry {
|
|
pub slug: String,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub content: String,
|
|
}
|