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:
2026-07-10 16:20:22 +02:00
parent fe6d00ce6d
commit d6af65f494
11 changed files with 544 additions and 7 deletions
+33
View File
@@ -159,6 +159,9 @@ struct EngineInner {
/// LSP diagnostics pool (edit/write surface errors). Shared across sessions; servers spawn
/// lazily on first touch.
diagnostics: Option<Arc<dyn DiagnosticsSource>>,
/// Pre-rendered "## Skills" system block (name + description), injected into every run.
/// `None` when no skills are configured.
skills_prompt: Option<String>,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -217,6 +220,14 @@ impl EngineHandle {
tools.register(tool);
}
// Skills: layered global + project markdown, advertised in the system prompt and pulled
// on demand by the `skill` tool (only registered when at least one skill exists).
let global_skill_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("skill"));
let project_skill_dir = cwd.join(".harness").join("skill");
let skills = config::load_skills(global_skill_dir.as_deref(), Some(&project_skill_dir));
harness_tools::register_skill_tool(&mut tools, &skills);
let skills_prompt = config::skills_prompt(&skills);
// Layered agent registry: bundled markdown → global dir → project dir → config patches.
let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent"));
let project_agent_dir = cwd.join(".harness").join("agent");
@@ -259,6 +270,7 @@ impl EngineHandle {
catalog,
agents,
diagnostics,
skills_prompt,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -395,6 +407,7 @@ impl EngineHandle {
inject_job_board,
reminder_turn_start: self.inner.reminder("turn_start"),
reminder_after_file_tool: self.inner.reminder("after_file_tool"),
skills_prompt: self.inner.skills_prompt.clone(),
};
// Reserve the run slot *before* spawning. If we inserted after spawning, a run that
@@ -807,6 +820,7 @@ impl SubagentSpawner for EngineInner {
inject_job_board: agent.mode.is_primary(),
reminder_turn_start: self.reminder("turn_start"),
reminder_after_file_tool: self.reminder("after_file_tool"),
skills_prompt: self.skills_prompt.clone(),
};
// Register the launch on the parent board (also makes foreground results reusable).
@@ -1321,6 +1335,25 @@ mod tests {
assert_eq!(app.engine.inner.tools.all().len(), 7);
}
#[tokio::test]
async fn project_skill_registers_the_skill_tool_and_advertises_it() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".harness").join("skill").join("fmt");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: format the code\n---\nRun cargo fmt.",
)
.unwrap();
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
// 6 built-ins + task + skill.
assert_eq!(engine.inner.tools.all().len(), 8);
assert!(engine.inner.tools.get("skill").is_some());
let prompt = engine.inner.skills_prompt.clone().expect("skills advertised");
assert!(prompt.contains("**fmt** — format the code"));
}
#[tokio::test]
async fn run_prompt_errors_on_unregistered_provider() {
let dir = tempfile::tempdir().unwrap();