M5: skills (SKILL.md and skill tool)
Adds harness-core::config::markdown to parse SKILL.md frontmatter/body, a skill tool in harness-tools to invoke them, and system-prompt wiring so available skills are advertised to the model. Includes the rustfmt pass over the M5 files touched by this and the preceding LSP/rmcp work.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
//! Markdown-defined commands and skills (M5). Both are YAML-frontmatter + body files loaded
|
||||
//! from a global dir (`~/.config/ai-harness/<kind>/`) and a project dir (`<project>/.harness/
|
||||
//! <kind>/`), project winning by name. See `docs/06-config.md` and `docs/09-integrations.md`.
|
||||
//!
|
||||
//! - Commands (`command/*.md`) are a pure input-layer concern: `/name args` expands the body
|
||||
//! template (`$ARGUMENTS`, `$1..$9`) into the user message, optionally switching agent/model.
|
||||
//! - Skills (`skill/<name>/SKILL.md`) advertise `name + description` in the system prompt; the
|
||||
//! model pulls a skill's body on demand via the built-in `skill` tool.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A slash command: a named prompt template with an optional agent/model override.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandDef {
|
||||
/// Invocation name (the file stem); used as `/name`.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Run the expanded prompt under this agent instead of the session's default.
|
||||
pub agent: Option<String>,
|
||||
/// Run under this `provider/model` instead of the session's default.
|
||||
pub model: Option<String>,
|
||||
/// The body, with `$ARGUMENTS` / `$1..$9` placeholders.
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
impl CommandDef {
|
||||
/// Substitutes `$ARGUMENTS` (the whole argument string) and `$1..$9` (whitespace-split
|
||||
/// positionals; missing ones become empty) into the template.
|
||||
pub fn expand(&self, arguments: &str) -> String {
|
||||
let positionals: Vec<&str> = arguments.split_whitespace().collect();
|
||||
let mut out = self.template.replace("$ARGUMENTS", arguments);
|
||||
for i in 1..=9 {
|
||||
let value = positionals.get(i - 1).copied().unwrap_or("");
|
||||
out = out.replace(&format!("${i}"), value);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A skill: advertised by `name + description`, body loaded on demand by the `skill` tool.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SkillDef {
|
||||
/// Skill name (the containing directory name); used as the `skill` tool argument.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Frontmatter fields shared by commands (all optional).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct CommandFrontmatter {
|
||||
description: Option<String>,
|
||||
agent: Option<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct SkillFrontmatter {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// Splits `---\n…\n---\n` frontmatter from the body. A file with no leading fence is all body.
|
||||
fn split_frontmatter(content: &str) -> (&str, &str) {
|
||||
let rest = match content
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| content.strip_prefix("---\r\n"))
|
||||
{
|
||||
Some(r) => r,
|
||||
None => return ("", content),
|
||||
};
|
||||
for delim in ["\n---\n", "\n---\r\n"] {
|
||||
if let Some(idx) = rest.find(delim) {
|
||||
return (&rest[..idx], &rest[idx + delim.len()..]);
|
||||
}
|
||||
}
|
||||
if rest.ends_with("\n---") {
|
||||
return (rest.trim_end_matches("\n---"), "");
|
||||
}
|
||||
("", content)
|
||||
}
|
||||
|
||||
fn parse_command(name: &str, content: &str) -> CommandDef {
|
||||
let (fm_raw, body) = split_frontmatter(content);
|
||||
let fm: CommandFrontmatter = if fm_raw.trim().is_empty() {
|
||||
CommandFrontmatter::default()
|
||||
} else {
|
||||
serde_yaml_ng::from_str(fm_raw).unwrap_or_default()
|
||||
};
|
||||
CommandDef {
|
||||
name: name.to_string(),
|
||||
description: fm.description.unwrap_or_default(),
|
||||
agent: fm.agent,
|
||||
model: fm.model,
|
||||
template: body.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill(name: &str, content: &str) -> SkillDef {
|
||||
let (fm_raw, body) = split_frontmatter(content);
|
||||
let fm: SkillFrontmatter = if fm_raw.trim().is_empty() {
|
||||
SkillFrontmatter::default()
|
||||
} else {
|
||||
serde_yaml_ng::from_str(fm_raw).unwrap_or_default()
|
||||
};
|
||||
SkillDef {
|
||||
name: name.to_string(),
|
||||
description: fm.description.unwrap_or_default(),
|
||||
body: body.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads `command/*.md` from global then project dirs (project wins by name).
|
||||
pub fn load_commands(
|
||||
global_dir: Option<&Path>,
|
||||
project_dir: Option<&Path>,
|
||||
) -> HashMap<String, CommandDef> {
|
||||
let mut commands = HashMap::new();
|
||||
for dir in [global_dir, project_dir].into_iter().flatten() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
commands.insert(name.to_string(), parse_command(name, &content));
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// Loads `skill/<name>/SKILL.md` from global then project dirs (project wins by name),
|
||||
/// returned name-sorted for a stable system-prompt listing.
|
||||
pub fn load_skills(global_dir: Option<&Path>, project_dir: Option<&Path>) -> Vec<SkillDef> {
|
||||
let mut by_name: HashMap<String, SkillDef> = HashMap::new();
|
||||
for dir in [global_dir, project_dir].into_iter().flatten() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if let Ok(content) = std::fs::read_to_string(&skill_file) {
|
||||
by_name.insert(name.to_string(), parse_skill(name, &content));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut skills: Vec<SkillDef> = by_name.into_values().collect();
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
skills
|
||||
}
|
||||
|
||||
/// The system-prompt section advertising available skills (name + description). `None` when
|
||||
/// there are no skills, so no empty section is injected.
|
||||
pub fn skills_prompt(skills: &[SkillDef]) -> Option<String> {
|
||||
if skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut section = String::from(
|
||||
"## Skills\n\nThese skills are available. Load a skill's full instructions on demand \
|
||||
by calling the `skill` tool with its name before doing the related work:\n",
|
||||
);
|
||||
for skill in skills {
|
||||
section.push_str(&format!("- **{}** — {}\n", skill.name, skill.description));
|
||||
}
|
||||
Some(section.trim_end().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_substitutes_arguments_and_positionals() {
|
||||
let cmd = CommandDef {
|
||||
name: "greet".into(),
|
||||
description: String::new(),
|
||||
agent: None,
|
||||
model: None,
|
||||
template: "Say $1 to $2. All: $ARGUMENTS".into(),
|
||||
};
|
||||
assert_eq!(cmd.expand("hi there"), "Say hi to there. All: hi there");
|
||||
// Missing positionals collapse to empty.
|
||||
assert_eq!(cmd.expand("solo"), "Say solo to . All: solo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_reads_frontmatter_and_body() {
|
||||
let md = "---\ndescription: review a PR\nagent: oracle\nmodel: openai/gpt-5\n---\nReview $ARGUMENTS please.\n";
|
||||
let cmd = parse_command("review", md);
|
||||
assert_eq!(cmd.description, "review a PR");
|
||||
assert_eq!(cmd.agent.as_deref(), Some("oracle"));
|
||||
assert_eq!(cmd.model.as_deref(), Some("openai/gpt-5"));
|
||||
assert_eq!(cmd.template, "Review $ARGUMENTS please.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_without_frontmatter_is_all_template() {
|
||||
let cmd = parse_command("x", "just do $1");
|
||||
assert_eq!(cmd.template, "just do $1");
|
||||
assert!(cmd.agent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_reads_description_and_body() {
|
||||
let md = "---\ndescription: format code\n---\nRun the formatter.\n";
|
||||
let skill = parse_skill("formatter", md);
|
||||
assert_eq!(skill.name, "formatter");
|
||||
assert_eq!(skill.description, "format code");
|
||||
assert_eq!(skill.body, "Run the formatter.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_prompt_lists_each_and_is_none_when_empty() {
|
||||
assert!(skills_prompt(&[]).is_none());
|
||||
let skills = vec![
|
||||
SkillDef {
|
||||
name: "a".into(),
|
||||
description: "does a".into(),
|
||||
body: "…".into(),
|
||||
},
|
||||
SkillDef {
|
||||
name: "b".into(),
|
||||
description: "does b".into(),
|
||||
body: "…".into(),
|
||||
},
|
||||
];
|
||||
let prompt = skills_prompt(&skills).unwrap();
|
||||
assert!(prompt.contains("- **a** — does a"));
|
||||
assert!(prompt.contains("- **b** — does b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_reads_skill_dirs_and_project_wins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global = dir.path().join("global");
|
||||
let project = dir.path().join("project");
|
||||
std::fs::create_dir_all(global.join("fmt")).unwrap();
|
||||
std::fs::create_dir_all(project.join("fmt")).unwrap();
|
||||
std::fs::create_dir_all(global.join("lint")).unwrap();
|
||||
std::fs::write(
|
||||
global.join("fmt/SKILL.md"),
|
||||
"---\ndescription: global fmt\n---\nglobal body",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
project.join("fmt/SKILL.md"),
|
||||
"---\ndescription: project fmt\n---\nproject body",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
global.join("lint/SKILL.md"),
|
||||
"---\ndescription: lint\n---\nlint body",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills(Some(&global), Some(&project));
|
||||
assert_eq!(skills.len(), 2);
|
||||
// Sorted by name: fmt, lint.
|
||||
assert_eq!(skills[0].name, "fmt");
|
||||
assert_eq!(skills[0].description, "project fmt"); // project overrode global
|
||||
assert_eq!(skills[0].body, "project body");
|
||||
assert_eq!(skills[1].name, "lint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_commands_project_overrides_global() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global = dir.path().join("g");
|
||||
let project = dir.path().join("p");
|
||||
std::fs::create_dir_all(&global).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::write(global.join("deploy.md"), "global deploy").unwrap();
|
||||
std::fs::write(project.join("deploy.md"), "project deploy").unwrap();
|
||||
let commands = load_commands(Some(&global), Some(&project));
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands["deploy"].template, "project deploy");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod load;
|
||||
pub mod markdown;
|
||||
pub mod schema;
|
||||
|
||||
pub use load::{load, ConfigError};
|
||||
pub use markdown::{load_commands, load_skills, skills_prompt, CommandDef, SkillDef};
|
||||
pub use schema::{
|
||||
AgentPatch, Config, LspServerConfig, McpServerConfig, OrchestrationConfig, ProviderConfig,
|
||||
TuiConfig,
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct RunConfig {
|
||||
pub reminder_turn_start: Option<String>,
|
||||
/// Optional user-provided reminder injected on the turn after a file tool ran.
|
||||
pub reminder_after_file_tool: Option<String>,
|
||||
/// Pre-rendered "## Skills" system block advertising loadable skills. `None` = no skills.
|
||||
pub skills_prompt: Option<String>,
|
||||
}
|
||||
|
||||
/// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is
|
||||
@@ -240,6 +242,7 @@ pub async fn run_session(
|
||||
let system_blocks = system::assemble(
|
||||
system::env_header(&ctx.cwd),
|
||||
&run_config.agent_prompt,
|
||||
run_config.skills_prompt.as_deref(),
|
||||
&run_config.instructions,
|
||||
);
|
||||
let tools: Vec<ToolSchema> = ctx
|
||||
@@ -519,6 +522,7 @@ mod tests {
|
||||
inject_job_board: false,
|
||||
reminder_turn_start: None,
|
||||
reminder_after_file_tool: None,
|
||||
skills_prompt: None,
|
||||
};
|
||||
|
||||
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||
@@ -629,6 +633,7 @@ mod tests {
|
||||
inject_job_board: false,
|
||||
reminder_turn_start: None,
|
||||
reminder_after_file_tool: None,
|
||||
skills_prompt: None,
|
||||
};
|
||||
|
||||
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||
@@ -736,6 +741,7 @@ mod tests {
|
||||
inject_job_board: true,
|
||||
reminder_turn_start: None,
|
||||
reminder_after_file_tool: None,
|
||||
skills_prompt: None,
|
||||
};
|
||||
|
||||
let provider = std::sync::Arc::new(CapturingProvider {
|
||||
@@ -803,6 +809,7 @@ mod tests {
|
||||
inject_job_board: false,
|
||||
reminder_turn_start: Some("REMEMBER: stay on task.".into()),
|
||||
reminder_after_file_tool: None,
|
||||
skills_prompt: None,
|
||||
};
|
||||
|
||||
let provider = std::sync::Arc::new(CapturingProvider {
|
||||
|
||||
@@ -25,10 +25,18 @@ pub fn env_header(cwd: &Path) -> String {
|
||||
header
|
||||
}
|
||||
|
||||
/// Ordered system prompt blocks: environment header, agent prompt, then project
|
||||
/// instructions (e.g. AGENTS.md contents). Order matches `02-engine.md`.
|
||||
pub fn assemble(env_header: String, agent_prompt: &str, instructions: &[String]) -> Vec<String> {
|
||||
/// Ordered system prompt blocks: environment header, agent prompt, the optional skills
|
||||
/// listing, then project instructions (e.g. AGENTS.md contents). Order matches `02-engine.md`.
|
||||
pub fn assemble(
|
||||
env_header: String,
|
||||
agent_prompt: &str,
|
||||
skills: Option<&str>,
|
||||
instructions: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut blocks = vec![env_header, agent_prompt.to_string()];
|
||||
if let Some(skills) = skills {
|
||||
blocks.push(skills.to_string());
|
||||
}
|
||||
blocks.extend(instructions.iter().cloned());
|
||||
blocks
|
||||
}
|
||||
@@ -49,8 +57,20 @@ mod tests {
|
||||
let blocks = assemble(
|
||||
"ENV".to_string(),
|
||||
"AGENT",
|
||||
None,
|
||||
&["AGENTS.md contents".to_string()],
|
||||
);
|
||||
assert_eq!(blocks, vec!["ENV", "AGENT", "AGENTS.md contents"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_inserts_skills_after_agent_before_instructions() {
|
||||
let blocks = assemble(
|
||||
"ENV".to_string(),
|
||||
"AGENT",
|
||||
Some("SKILLS"),
|
||||
&["INSTR".to_string()],
|
||||
);
|
||||
assert_eq!(blocks, vec!["ENV", "AGENT", "SKILLS", "INSTR"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user