mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Add skills system for reusable prompt templates
Skills are markdown files with YAML frontmatter that define reusable
prompt templates (e.g., /commit, /review-pr). When a user references
/skill-name in their input, the skill template expands in place with
{{user_input}} receiving the remaining text.
- Add skills.rs with parse, expand, discover, and formatting functions
- Discover skills from ~/.attractor/skills/, <git-root>/.attractor/skills/,
and <git-root>/skills/ (overridable with --skills-dir CLI flag)
- Inject available skills into system prompt between project docs and
user instructions
- Expand skill references in session input before recording user turn
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2a8ed77f26c5
This commit is contained in:
parent
1ec822781c
commit
3d39e06c29
13 changed files with 558 additions and 23 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -11,6 +11,7 @@ dependencies = [
|
|||
"bollard",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"glob",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ async-trait.workspace = true
|
|||
jsonschema.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio-util.workspace = true
|
||||
dirs = "6"
|
||||
glob = "0.3"
|
||||
bollard = { workspace = true, optional = true }
|
||||
tar = { workspace = true, optional = true }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ struct Cli {
|
|||
/// Print full LLM request/response JSON to stderr
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Directory containing skill files (overrides default discovery)
|
||||
#[arg(long)]
|
||||
skills_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
|
|
@ -330,6 +334,7 @@ pub async fn run() -> anyhow::Result<()> {
|
|||
|
||||
let config = SessionConfig {
|
||||
tool_approval: Some(tool_approval),
|
||||
skill_dirs: cli.skills_dir.map(|d| vec![d]),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct SessionConfig {
|
|||
pub enable_context_compaction: bool,
|
||||
pub compaction_threshold_percent: usize,
|
||||
pub compaction_preserve_turns: usize,
|
||||
/// Skill directories. `None` = use convention defaults, `Some(dirs)` = use these instead.
|
||||
pub skill_dirs: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SessionConfig {
|
||||
|
|
@ -54,6 +56,7 @@ impl std::fmt::Debug for SessionConfig {
|
|||
.field("enable_context_compaction", &self.enable_context_compaction)
|
||||
.field("compaction_threshold_percent", &self.compaction_threshold_percent)
|
||||
.field("compaction_preserve_turns", &self.compaction_preserve_turns)
|
||||
.field("skill_dirs", &self.skill_dirs)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +80,7 @@ impl Default for SessionConfig {
|
|||
enable_context_compaction: true,
|
||||
compaction_threshold_percent: 80,
|
||||
compaction_preserve_turns: 6,
|
||||
skill_dirs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod loop_detection;
|
|||
pub mod profiles;
|
||||
pub mod project_docs;
|
||||
pub mod read_before_write_env;
|
||||
pub mod skills;
|
||||
pub mod provider_profile;
|
||||
pub mod session;
|
||||
pub mod subagent;
|
||||
|
|
@ -31,6 +32,7 @@ pub use local_env::LocalExecutionEnvironment;
|
|||
pub use loop_detection::detect_loop;
|
||||
pub use read_before_write_env::ReadBeforeWriteEnvironment;
|
||||
pub use project_docs::discover_project_docs;
|
||||
pub use skills::Skill;
|
||||
pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};
|
||||
pub use provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
pub use session::Session;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use crate::execution_env::ExecutionEnvironment;
|
|||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{
|
||||
make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool,
|
||||
|
|
@ -64,6 +65,7 @@ impl ProviderProfile for AnthropicProfile {
|
|||
env_context: &EnvContext,
|
||||
project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let core_prompt = "\
|
||||
You are Claude, an AI coding assistant made by Anthropic. You help users with software \
|
||||
|
|
@ -134,7 +136,7 @@ finding files rather than using shell find or ls commands.
|
|||
Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \
|
||||
in the project. Keep changes minimal and focused on the task.";
|
||||
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions)
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
|
|
@ -207,7 +209,7 @@ mod tests {
|
|||
fn anthropic_system_prompt_contains_env_context() {
|
||||
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("You are Claude, an AI coding assistant made by Anthropic"));
|
||||
assert!(prompt.contains("<environment>"));
|
||||
assert!(prompt.contains("linux"));
|
||||
|
|
@ -241,7 +243,7 @@ mod tests {
|
|||
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()];
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]);
|
||||
assert!(prompt.contains("# Project README"));
|
||||
assert!(prompt.contains("# CONTRIBUTING guide"));
|
||||
}
|
||||
|
|
@ -259,7 +261,7 @@ mod tests {
|
|||
git_status_short: None,
|
||||
git_recent_commits: None,
|
||||
};
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], None, &[]);
|
||||
assert!(prompt.contains("Git branch: feature-branch"));
|
||||
assert!(prompt.contains("Is git repository: true"));
|
||||
assert!(prompt.contains("Today's date: 2026-02-20"));
|
||||
|
|
@ -272,7 +274,7 @@ mod tests {
|
|||
let profile = AnthropicProfile::new("claude-opus-4-6");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let ctx = EnvContext::default();
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always write tests first"));
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always write tests first"), &[]);
|
||||
assert!(prompt.contains("Always write tests first"));
|
||||
assert!(prompt.contains("# User Instructions"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use crate::execution_env::ExecutionEnvironment;
|
|||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{
|
||||
make_edit_file_tool, make_glob_tool, make_grep_tool, make_list_dir_tool,
|
||||
|
|
@ -64,6 +65,7 @@ impl ProviderProfile for GeminiProfile {
|
|||
env_context: &EnvContext,
|
||||
project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let core_prompt = "\
|
||||
You are Gemini CLI, an interactive CLI agent specializing in software engineering tasks \
|
||||
|
|
@ -181,7 +183,7 @@ These are foundational mandates that take precedence over defaults in this promp
|
|||
Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \
|
||||
in the project.";
|
||||
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions)
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
|
|
@ -235,7 +237,7 @@ mod tests {
|
|||
fn gemini_system_prompt_contains_identity() {
|
||||
let profile = GeminiProfile::new("gemini-2.0-flash");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("You are Gemini CLI"));
|
||||
assert!(prompt.contains("solving bugs"));
|
||||
assert!(prompt.contains("adding new functionality"));
|
||||
|
|
@ -247,7 +249,7 @@ mod tests {
|
|||
fn gemini_system_prompt_contains_tool_guidance() {
|
||||
let profile = GeminiProfile::new("gemini-2.0-flash");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("read_file"));
|
||||
assert!(prompt.contains("read_many_files"));
|
||||
assert!(prompt.contains("edit_file"));
|
||||
|
|
@ -265,7 +267,7 @@ mod tests {
|
|||
fn gemini_system_prompt_contains_project_docs_convention() {
|
||||
let profile = GeminiProfile::new("gemini-2.0-flash");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("GEMINI.md"));
|
||||
assert!(prompt.contains("AGENTS.md"));
|
||||
}
|
||||
|
|
@ -274,7 +276,7 @@ mod tests {
|
|||
fn gemini_system_prompt_contains_coding_best_practices() {
|
||||
let profile = GeminiProfile::new("gemini-2.0-flash");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("clean, maintainable code"));
|
||||
assert!(prompt.contains("Handle errors appropriately"));
|
||||
assert!(prompt.contains("existing code conventions"));
|
||||
|
|
@ -284,7 +286,7 @@ mod tests {
|
|||
fn gemini_system_prompt_contains_env_context() {
|
||||
let profile = GeminiProfile::new("gemini-2.0-flash");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("<environment>"));
|
||||
assert!(prompt.contains("linux"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub use gemini::GeminiProfile;
|
|||
pub use openai::OpenAiProfile;
|
||||
|
||||
use crate::execution_env::ExecutionEnvironment;
|
||||
use crate::skills::{format_skills_prompt_section, Skill};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
|
||||
/// Common fields shared by all provider profiles.
|
||||
|
|
@ -42,6 +43,7 @@ pub fn assemble_system_prompt(
|
|||
env_context: &EnvContext,
|
||||
project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let env_block = build_env_context_block_with(env, env_context);
|
||||
let docs_section = if project_docs.is_empty() {
|
||||
|
|
@ -49,13 +51,21 @@ pub fn assemble_system_prompt(
|
|||
} else {
|
||||
format!("\n\n{}", project_docs.join("\n\n"))
|
||||
};
|
||||
let skills_section = {
|
||||
let s = format_skills_prompt_section(skills);
|
||||
if s.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n{s}")
|
||||
}
|
||||
};
|
||||
let user_section = match user_instructions {
|
||||
Some(instructions) => format!("\n\n# User Instructions\n{instructions}"),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let prompt = core_prompt.replace("{env_block}", &env_block);
|
||||
format!("{prompt}{docs_section}{user_section}")
|
||||
format!("{prompt}{docs_section}{skills_section}{user_section}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use crate::execution_env::ExecutionEnvironment;
|
|||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::{RegisteredTool, ToolRegistry};
|
||||
use llm::types::ToolDefinition;
|
||||
use crate::tools::{
|
||||
|
|
@ -66,6 +67,7 @@ impl ProviderProfile for OpenAiProfile {
|
|||
env_context: &EnvContext,
|
||||
project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let core_prompt = "\
|
||||
You are a coding agent powered by OpenAI, running in a terminal-based agentic coding assistant. \
|
||||
|
|
@ -147,7 +149,7 @@ Find files by name pattern.
|
|||
Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \
|
||||
in the project.";
|
||||
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions)
|
||||
assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
|
|
@ -442,7 +444,7 @@ mod tests {
|
|||
fn openai_system_prompt_contains_env_context() {
|
||||
let profile = OpenAiProfile::new("o3-mini");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("You are a coding agent powered by OpenAI"));
|
||||
assert!(prompt.contains("<environment>"));
|
||||
assert!(prompt.contains("linux"));
|
||||
|
|
@ -454,7 +456,7 @@ mod tests {
|
|||
fn openai_system_prompt_contains_tool_guidance() {
|
||||
let profile = OpenAiProfile::new("o3-mini");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("read_file"));
|
||||
assert!(prompt.contains("apply_patch"));
|
||||
assert!(prompt.contains("write_file"));
|
||||
|
|
@ -468,7 +470,7 @@ mod tests {
|
|||
fn openai_system_prompt_contains_coding_best_practices() {
|
||||
let profile = OpenAiProfile::new("o3-mini");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("clean, maintainable code"));
|
||||
assert!(prompt.contains("existing code conventions"));
|
||||
}
|
||||
|
|
@ -478,7 +480,7 @@ mod tests {
|
|||
let profile = OpenAiProfile::new("o3-mini");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()];
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None);
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]);
|
||||
assert!(prompt.contains("# Project README"));
|
||||
assert!(prompt.contains("# CONTRIBUTING guide"));
|
||||
}
|
||||
|
|
@ -487,7 +489,7 @@ mod tests {
|
|||
fn openai_system_prompt_includes_user_instructions() {
|
||||
let profile = OpenAiProfile::new("o3-mini");
|
||||
let env = MockExecutionEnvironment::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], Some("Always write tests first"));
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], Some("Always write tests first"), &[]);
|
||||
assert!(prompt.contains("Always write tests first"));
|
||||
assert!(prompt.contains("# User Instructions"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::execution_env::ExecutionEnvironment;
|
||||
use crate::profiles::EnvContext;
|
||||
use crate::skills::Skill;
|
||||
use crate::subagent::{
|
||||
make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, SessionFactory,
|
||||
SubAgentManager,
|
||||
|
|
@ -27,6 +28,7 @@ pub trait ProviderProfile: Send + Sync {
|
|||
env_context: &EnvContext,
|
||||
project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String;
|
||||
fn capabilities(&self) -> ProfileCapabilities;
|
||||
fn knowledge_cutoff(&self) -> &str;
|
||||
|
|
@ -102,7 +104,7 @@ mod tests {
|
|||
let env = MockExecutionEnvironment::linux();
|
||||
let ctx = EnvContext::default();
|
||||
let docs = vec!["README.md contents".into()];
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &docs, None);
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &docs, None, &[]);
|
||||
assert!(prompt.contains("test assistant"));
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +113,7 @@ mod tests {
|
|||
let profile = TestProfile::new();
|
||||
let env = MockExecutionEnvironment::default();
|
||||
let ctx = EnvContext::default();
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always use TDD"));
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always use TDD"), &[]);
|
||||
assert!(prompt.contains("Always use TDD"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::loop_detection::detect_loop;
|
|||
use crate::profiles::EnvContext;
|
||||
use crate::project_docs::discover_project_docs;
|
||||
use crate::provider_profile::ProviderProfile;
|
||||
use crate::skills::{default_skill_dirs, discover_skills, expand_skill, Skill};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::truncation::truncate_tool_output;
|
||||
use crate::types::{AgentEvent, SessionState, Turn};
|
||||
|
|
@ -34,6 +35,7 @@ pub struct Session {
|
|||
cancel_token: CancellationToken,
|
||||
project_docs: Vec<String>,
|
||||
env_context: EnvContext,
|
||||
skills: Vec<Skill>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
|
@ -58,6 +60,7 @@ impl Session {
|
|||
cancel_token: CancellationToken::new(),
|
||||
project_docs: Vec::new(),
|
||||
env_context: EnvContext::default(),
|
||||
skills: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +83,16 @@ impl Session {
|
|||
)
|
||||
.await;
|
||||
|
||||
// Discover skills
|
||||
let skill_dirs = match &self.config.skill_dirs {
|
||||
Some(dirs) => dirs.clone(),
|
||||
None => {
|
||||
let home = dirs::home_dir().map(|p| p.to_string_lossy().to_string());
|
||||
default_skill_dirs(home.as_deref(), self.config.git_root.as_deref())
|
||||
}
|
||||
};
|
||||
self.skills = discover_skills(self.execution_env.as_ref(), &skill_dirs).await;
|
||||
|
||||
// Populate environment context
|
||||
self.env_context = self.build_env_context().await;
|
||||
}
|
||||
|
|
@ -222,9 +235,17 @@ impl Session {
|
|||
|
||||
self.state = SessionState::Processing;
|
||||
|
||||
// Expand skill references in input
|
||||
let expanded_input = if self.skills.is_empty() {
|
||||
input.to_string()
|
||||
} else {
|
||||
expand_skill(&self.skills, input)
|
||||
.map_err(AgentError::InvalidState)?
|
||||
};
|
||||
|
||||
// Append user turn and emit event
|
||||
self.history.push(Turn::User {
|
||||
content: input.to_string(),
|
||||
content: expanded_input.clone(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
self.event_emitter
|
||||
|
|
@ -239,6 +260,7 @@ impl Session {
|
|||
&self.env_context,
|
||||
&self.project_docs,
|
||||
self.config.user_instructions.as_deref(),
|
||||
&self.skills,
|
||||
);
|
||||
|
||||
let mut round_count: usize = 0;
|
||||
|
|
|
|||
474
crates/agent/src/skills.rs
Normal file
474
crates/agent/src/skills.rs
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
use crate::execution_env::ExecutionEnvironment;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
pub fn parse_skill(content: &str) -> Result<Skill, String> {
|
||||
let trimmed = content.trim();
|
||||
if !trimmed.starts_with("---") {
|
||||
return Err("Missing YAML frontmatter delimiters".into());
|
||||
}
|
||||
|
||||
let after_first = &trimmed[3..];
|
||||
let end_idx = after_first
|
||||
.find("\n---")
|
||||
.ok_or("Missing closing frontmatter delimiter")?;
|
||||
let frontmatter = &after_first[..end_idx];
|
||||
let body = &after_first[end_idx + 4..];
|
||||
|
||||
let mut name: Option<String> = None;
|
||||
let mut description = String::new();
|
||||
|
||||
for line in frontmatter.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(val) = line.strip_prefix("name:") {
|
||||
name = Some(val.trim().to_string());
|
||||
} else if let Some(val) = line.strip_prefix("description:") {
|
||||
description = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let name = name.ok_or("Missing required 'name' field in frontmatter")?;
|
||||
let template = body.trim().to_string();
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description,
|
||||
template,
|
||||
})
|
||||
}
|
||||
|
||||
/// A detected skill reference in user input: the name and byte range of the `/name` token.
|
||||
struct SkillMatch {
|
||||
name: String,
|
||||
/// Byte offset of the `/` character
|
||||
start: usize,
|
||||
/// Byte offset just past the skill name
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn is_skill_name_char(c: char) -> bool {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
|
||||
}
|
||||
|
||||
/// Find all `/skill-name` tokens in input where the `/` is preceded by whitespace (or
|
||||
/// start-of-string) and the name is followed by whitespace (or end-of-string).
|
||||
fn find_skill_references(input: &str) -> Vec<SkillMatch> {
|
||||
let mut results = Vec::new();
|
||||
let bytes = input.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if bytes[i] == b'/' {
|
||||
// Check that preceding char is whitespace or this is start of string
|
||||
let preceded_by_boundary = i == 0 || bytes[i - 1].is_ascii_whitespace();
|
||||
if !preceded_by_boundary {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The first char after `/` must be a lowercase letter
|
||||
let name_start = i + 1;
|
||||
if name_start >= len || !bytes[name_start].is_ascii_lowercase() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Consume the rest of the name
|
||||
let mut j = name_start + 1;
|
||||
while j < len && is_skill_name_char(bytes[j] as char) {
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Check that following char is whitespace or end of string
|
||||
let followed_by_boundary = j >= len || bytes[j].is_ascii_whitespace();
|
||||
if followed_by_boundary {
|
||||
results.push(SkillMatch {
|
||||
name: input[name_start..j].to_string(),
|
||||
start: i,
|
||||
end: j,
|
||||
});
|
||||
}
|
||||
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub fn expand_skill(skills: &[Skill], input: &str) -> Result<String, String> {
|
||||
let refs = find_skill_references(input);
|
||||
|
||||
if refs.is_empty() {
|
||||
return Ok(input.to_string());
|
||||
}
|
||||
|
||||
if refs.len() > 1 {
|
||||
return Err("Only one skill reference per input is allowed".into());
|
||||
}
|
||||
|
||||
let skill_ref = &refs[0];
|
||||
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|s| s.name == skill_ref.name)
|
||||
.ok_or_else(|| format!("Unknown skill: /{}", skill_ref.name))?;
|
||||
|
||||
// Remove the /skill-name token from input to get user_input
|
||||
let before = &input[..skill_ref.start];
|
||||
let after = &input[skill_ref.end..];
|
||||
let user_input = format!("{before}{after}").trim().to_string();
|
||||
|
||||
if skill.template.contains("{{user_input}}") {
|
||||
Ok(skill.template.replace("{{user_input}}", &user_input))
|
||||
} else {
|
||||
Ok(skill.template.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_skills_prompt_section(skills: &[Skill]) -> String {
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut lines = vec!["# Available Skills".to_string()];
|
||||
for skill in skills {
|
||||
if skill.description.is_empty() {
|
||||
lines.push(format!("- /{}", skill.name));
|
||||
} else {
|
||||
lines.push(format!("- /{}: {}", skill.name, skill.description));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn default_skill_dirs(home_dir: Option<&str>, git_root: Option<&str>) -> Vec<String> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
dirs.push(format!("{home}/.attractor/skills"));
|
||||
}
|
||||
|
||||
if let Some(root) = git_root {
|
||||
dirs.push(format!("{root}/.attractor/skills"));
|
||||
dirs.push(format!("{root}/skills"));
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
pub async fn discover_skills(
|
||||
env: &dyn ExecutionEnvironment,
|
||||
dirs: &[String],
|
||||
) -> Vec<Skill> {
|
||||
let mut skills_by_name: std::collections::HashMap<String, Skill> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for dir in dirs {
|
||||
let paths = match env.glob("*.md", Some(dir)).await {
|
||||
Ok(paths) => paths,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for path in paths {
|
||||
let content = match env.read_file(&path, None, None).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
match parse_skill(&content) {
|
||||
Ok(skill) => {
|
||||
skills_by_name.insert(skill.name.clone(), skill);
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut skills: Vec<Skill> = skills_by_name.into_values().collect();
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
skills
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::MockExecutionEnvironment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// --- parse_skill tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_skill_basic() {
|
||||
let content = "\
|
||||
---
|
||||
name: commit
|
||||
description: Create a git commit following best practices
|
||||
---
|
||||
|
||||
Review staged and unstaged changes, then create a well-crafted commit.
|
||||
|
||||
{{user_input}}";
|
||||
|
||||
let skill = parse_skill(content).unwrap();
|
||||
assert_eq!(skill.name, "commit");
|
||||
assert_eq!(
|
||||
skill.description,
|
||||
"Create a git commit following best practices"
|
||||
);
|
||||
assert!(skill.template.contains("Review staged"));
|
||||
assert!(skill.template.contains("{{user_input}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_no_frontmatter() {
|
||||
let content = "Just some markdown without frontmatter";
|
||||
let result = parse_skill(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_missing_name() {
|
||||
let content = "\
|
||||
---
|
||||
description: A skill without a name
|
||||
---
|
||||
|
||||
Some template";
|
||||
|
||||
let result = parse_skill(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_description_optional() {
|
||||
let content = "\
|
||||
---
|
||||
name: simple
|
||||
---
|
||||
|
||||
Just a template";
|
||||
|
||||
let skill = parse_skill(content).unwrap();
|
||||
assert_eq!(skill.name, "simple");
|
||||
assert_eq!(skill.description, "");
|
||||
assert_eq!(skill.template, "Just a template");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_trims_template() {
|
||||
let content = "\
|
||||
---
|
||||
name: trimmed
|
||||
---
|
||||
|
||||
|
||||
Body with leading/trailing whitespace
|
||||
|
||||
|
||||
";
|
||||
|
||||
let skill = parse_skill(content).unwrap();
|
||||
assert_eq!(skill.template, "Body with leading/trailing whitespace");
|
||||
}
|
||||
|
||||
// --- expand_skill tests ---
|
||||
|
||||
fn test_skills() -> Vec<Skill> {
|
||||
vec![
|
||||
Skill {
|
||||
name: "commit".into(),
|
||||
description: "Create a commit".into(),
|
||||
template: "Review changes and commit.\n\n{{user_input}}".into(),
|
||||
},
|
||||
Skill {
|
||||
name: "test".into(),
|
||||
description: "Run tests".into(),
|
||||
template: "Run the test suite.".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_no_skill_reference() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "just some plain text").unwrap();
|
||||
assert_eq!(result, "just some plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_skill_at_start() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/commit do the thing").unwrap();
|
||||
assert_eq!(result, "Review changes and commit.\n\ndo the thing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_skill_mid_line() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "please /commit the auth changes").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
"Review changes and commit.\n\nplease the auth changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_skill_alone() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/commit").unwrap();
|
||||
assert_eq!(result, "Review changes and commit.\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_unknown_skill() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_does_not_match_paths() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/usr/bin/bash").unwrap();
|
||||
assert_eq!(result, "/usr/bin/bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_multiple_skills_errors() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/commit and /test");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Only one skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_template_without_placeholder() {
|
||||
let skills = test_skills();
|
||||
let result = expand_skill(&skills, "/test please run").unwrap();
|
||||
assert_eq!(result, "Run the test suite.");
|
||||
}
|
||||
|
||||
// --- format_skills_prompt_section tests ---
|
||||
|
||||
#[test]
|
||||
fn format_empty() {
|
||||
assert_eq!(format_skills_prompt_section(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_lists_skills() {
|
||||
let skills = test_skills();
|
||||
let section = format_skills_prompt_section(&skills);
|
||||
assert!(section.contains("# Available Skills"));
|
||||
assert!(section.contains("- /commit: Create a commit"));
|
||||
assert!(section.contains("- /test: Run tests"));
|
||||
}
|
||||
|
||||
// --- discover_skills tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_loads_files() {
|
||||
let mut files = HashMap::new();
|
||||
files.insert(
|
||||
"/skills/commit.md".into(),
|
||||
"---\nname: commit\ndescription: Make a commit\n---\nDo commit".into(),
|
||||
);
|
||||
let env = MockExecutionEnvironment {
|
||||
files,
|
||||
glob_results: vec!["/skills/commit.md".into()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let skills = discover_skills(&env, &["/skills".into()]).await;
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "commit");
|
||||
assert_eq!(skills[0].description, "Make a commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_skips_invalid() {
|
||||
let mut files = HashMap::new();
|
||||
files.insert(
|
||||
"/skills/good.md".into(),
|
||||
"---\nname: good\n---\nGood template".into(),
|
||||
);
|
||||
files.insert("/skills/bad.md".into(), "no frontmatter here".into());
|
||||
let env = MockExecutionEnvironment {
|
||||
files,
|
||||
glob_results: vec!["/skills/good.md".into(), "/skills/bad.md".into()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let skills = discover_skills(&env, &["/skills".into()]).await;
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "good");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_empty_dirs() {
|
||||
let env = MockExecutionEnvironment::default();
|
||||
let skills = discover_skills(&env, &[]).await;
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_project_overrides_global() {
|
||||
let mut files = HashMap::new();
|
||||
files.insert(
|
||||
"/global/commit.md".into(),
|
||||
"---\nname: commit\ndescription: Global commit\n---\nGlobal template".into(),
|
||||
);
|
||||
files.insert(
|
||||
"/project/commit.md".into(),
|
||||
"---\nname: commit\ndescription: Project commit\n---\nProject template".into(),
|
||||
);
|
||||
|
||||
// We need separate envs because MockExecutionEnvironment returns the same glob_results
|
||||
// for all calls. Instead, we test with a single env that has both files
|
||||
// and glob returns both — the later dir overrides the earlier.
|
||||
let env = MockExecutionEnvironment {
|
||||
files,
|
||||
glob_results: vec!["/global/commit.md".into(), "/project/commit.md".into()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// discover_skills iterates dirs in order; later dirs override earlier names
|
||||
let skills =
|
||||
discover_skills(&env, &["/global".into(), "/project".into()]).await;
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].description, "Project commit");
|
||||
}
|
||||
|
||||
// --- default_skill_dirs tests ---
|
||||
|
||||
#[test]
|
||||
fn default_dirs_with_git_root() {
|
||||
let dirs = default_skill_dirs(Some("/home/user"), Some("/repo"));
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec![
|
||||
"/home/user/.attractor/skills",
|
||||
"/repo/.attractor/skills",
|
||||
"/repo/skills",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_dirs_without_git_root() {
|
||||
let dirs = default_skill_dirs(Some("/home/user"), None);
|
||||
assert_eq!(dirs, vec!["/home/user/.attractor/skills"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use crate::execution_env::*;
|
|||
use crate::profiles::EnvContext;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::session::Session;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -344,10 +345,17 @@ impl ProviderProfile for TestProfile {
|
|||
_env_context: &EnvContext,
|
||||
_project_docs: &[String],
|
||||
user_instructions: Option<&str>,
|
||||
skills: &[Skill],
|
||||
) -> String {
|
||||
let skills_section = crate::skills::format_skills_prompt_section(skills);
|
||||
let skills_part = if skills_section.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n{skills_section}")
|
||||
};
|
||||
match user_instructions {
|
||||
Some(instructions) => format!("You are a test assistant.\n\n# User Instructions\n{instructions}"),
|
||||
None => "You are a test assistant.".into(),
|
||||
Some(instructions) => format!("You are a test assistant.{skills_part}\n\n# User Instructions\n{instructions}"),
|
||||
None => format!("You are a test assistant.{skills_part}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue