use std::sync::Arc;
use cognis_llm::tools::Tool;
pub trait Skill: Send + Sync {
fn id(&self) -> &str;
fn description(&self) -> &str;
fn tools(&self) -> Vec<Arc<dyn Tool>>;
fn system_prompt_fragment(&self) -> Option<String> {
None
}
}
type ToolFactory = Arc<dyn Fn() -> Vec<Arc<dyn Tool>> + Send + Sync>;
pub struct BuiltSkill {
id: String,
description: String,
system_prompt: Option<String>,
tools_fn: ToolFactory,
}
impl Skill for BuiltSkill {
fn id(&self) -> &str {
&self.id
}
fn description(&self) -> &str {
&self.description
}
fn tools(&self) -> Vec<Arc<dyn Tool>> {
(self.tools_fn)()
}
fn system_prompt_fragment(&self) -> Option<String> {
self.system_prompt.clone()
}
}
pub struct SkillBuilder {
id: String,
description: String,
system_prompt: Option<String>,
tools_fn: Option<ToolFactory>,
}
impl SkillBuilder {
pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: id.into(),
description: description.into(),
system_prompt: None,
tools_fn: None,
}
}
pub fn with_system_prompt(mut self, fragment: impl Into<String>) -> Self {
self.system_prompt = Some(fragment.into());
self
}
pub fn with_tools<F>(mut self, factory: F) -> Self
where
F: Fn() -> Vec<Arc<dyn Tool>> + Send + Sync + 'static,
{
self.tools_fn = Some(Arc::new(factory));
self
}
pub fn with_static_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
let cloned = tools;
self.tools_fn = Some(Arc::new(move || cloned.clone()));
self
}
pub fn build(self) -> BuiltSkill {
BuiltSkill {
id: self.id,
description: self.description,
system_prompt: self.system_prompt,
tools_fn: self.tools_fn.unwrap_or_else(|| Arc::new(Vec::new)),
}
}
}
pub trait SkillSelector: Send + Sync {
fn select(&self, input: &str, skills: &[Arc<dyn Skill>]) -> Vec<usize>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AllSkills;
impl SkillSelector for AllSkills {
fn select(&self, _input: &str, skills: &[Arc<dyn Skill>]) -> Vec<usize> {
(0..skills.len()).collect()
}
}
pub struct KeywordSelector {
keywords: Vec<String>,
}
impl KeywordSelector {
pub fn new<I, S>(keywords: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
keywords: keywords
.into_iter()
.map(|s| s.into().to_lowercase())
.collect(),
}
}
}
impl SkillSelector for KeywordSelector {
fn select(&self, input: &str, skills: &[Arc<dyn Skill>]) -> Vec<usize> {
let lower = input.to_lowercase();
if !self.keywords.iter().any(|k| lower.contains(k)) {
return Vec::new();
}
(0..skills.len()).collect()
}
}
impl<F> SkillSelector for F
where
F: Fn(&str, &[Arc<dyn Skill>]) -> Vec<usize> + Send + Sync,
{
fn select(&self, input: &str, skills: &[Arc<dyn Skill>]) -> Vec<usize> {
(self)(input, skills)
}
}
#[derive(Default, Clone)]
pub struct SkillRegistry {
skills: Vec<Arc<dyn Skill>>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(mut self, skill: impl Skill + 'static) -> Self {
self.skills.push(Arc::new(skill));
self
}
pub fn register_arc(mut self, skill: Arc<dyn Skill>) -> Self {
self.skills.push(skill);
self
}
pub fn skills(&self) -> &[Arc<dyn Skill>] {
&self.skills
}
pub fn active_for<S: SkillSelector + ?Sized>(
&self,
selector: &S,
input: &str,
) -> Vec<&Arc<dyn Skill>> {
let idxs = selector.select(input, &self.skills);
idxs.into_iter()
.filter_map(|i| self.skills.get(i))
.collect()
}
pub fn collect_tools<'a, I>(active: I) -> Vec<Arc<dyn Tool>>
where
I: IntoIterator<Item = &'a Arc<dyn Skill>>,
{
let mut out = Vec::new();
for s in active {
out.extend(s.tools());
}
out
}
pub fn collect_prompt<'a, I>(active: I) -> String
where
I: IntoIterator<Item = &'a Arc<dyn Skill>>,
{
let mut parts: Vec<String> = Vec::new();
for s in active {
if let Some(p) = s.system_prompt_fragment() {
parts.push(p);
}
}
parts.join("\n\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use cognis_core::Result;
use cognis_llm::tools::{Tool, ToolInput, ToolOutput};
struct NoopTool(&'static str);
#[async_trait]
impl Tool for NoopTool {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> &str {
"noop"
}
fn args_schema(&self) -> Option<serde_json::Value> {
None
}
async fn _run(&self, _: ToolInput) -> Result<ToolOutput> {
Ok(ToolOutput::Empty)
}
}
#[test]
fn skill_builder_defaults() {
let s: BuiltSkill = SkillBuilder::new("web", "browse the web")
.with_system_prompt("you may browse the web")
.with_static_tools(vec![Arc::new(NoopTool("search"))])
.build();
assert_eq!(s.id(), "web");
assert_eq!(s.tools().len(), 1);
assert_eq!(
s.system_prompt_fragment().as_deref(),
Some("you may browse the web")
);
}
#[test]
fn registry_collects_active_tools_and_prompts() {
let reg = SkillRegistry::new()
.register(
SkillBuilder::new("a", "skill a")
.with_system_prompt("PA")
.with_static_tools(vec![Arc::new(NoopTool("ta"))])
.build(),
)
.register(
SkillBuilder::new("b", "skill b")
.with_system_prompt("PB")
.with_static_tools(vec![Arc::new(NoopTool("tb"))])
.build(),
);
let active = reg.active_for(&AllSkills, "anything");
assert_eq!(active.len(), 2);
let tools = SkillRegistry::collect_tools(active.iter().copied());
assert_eq!(tools.len(), 2);
let prompt = SkillRegistry::collect_prompt(active.iter().copied());
assert!(prompt.contains("PA"));
assert!(prompt.contains("PB"));
}
#[test]
fn keyword_selector_activates_only_on_match() {
let reg = SkillRegistry::new().register(
SkillBuilder::new("sql", "database queries")
.with_static_tools(vec![Arc::new(NoopTool("query"))])
.build(),
);
let sel = KeywordSelector::new(["sql", "database"]);
assert_eq!(reg.active_for(&sel, "what is rust").len(), 0);
assert_eq!(reg.active_for(&sel, "run a SQL query").len(), 1);
}
#[test]
fn closure_selector_works() {
let reg = SkillRegistry::new().register(
SkillBuilder::new("a", "always-on")
.with_static_tools(vec![Arc::new(NoopTool("t"))])
.build(),
);
let sel = |input: &str, skills: &[Arc<dyn Skill>]| -> Vec<usize> {
if input.contains('1') {
(0..skills.len()).collect()
} else {
Vec::new()
}
};
assert!(reg.active_for(&sel, "no digit here").is_empty());
assert_eq!(reg.active_for(&sel, "step 1 done").len(), 1);
}
}