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
+6 -1
View File
@@ -14,7 +14,12 @@ const DIAGNOSTICS_WAIT: Duration = Duration::from_millis(1500);
/// Touches `path` in the language server and appends error-severity diagnostics to `output`
/// (both as a human-readable block in the text and the full set in metadata under `diagnostics`).
pub async fn append_diagnostics(ctx: &ToolCtx, path: &Path, display_name: &str, output: &mut ToolOutput) {
pub async fn append_diagnostics(
ctx: &ToolCtx,
path: &Path,
display_name: &str,
output: &mut ToolOutput,
) {
let Some(source) = &ctx.diagnostics else {
return;
};
+10
View File
@@ -5,6 +5,7 @@ mod glob;
mod grep;
mod paths;
mod read;
mod skill;
mod task;
mod write;
@@ -13,6 +14,7 @@ pub use edit::EditTool;
pub use glob::GlobTool;
pub use grep::GrepTool;
pub use read::ReadTool;
pub use skill::SkillTool;
pub use task::TaskTool;
pub use write::WriteTool;
@@ -36,3 +38,11 @@ pub fn register_builtins(registry: &mut ToolRegistry) {
pub fn register_task_tool(registry: &mut ToolRegistry) {
registry.register(Arc::new(TaskTool));
}
/// Registers the `skill` tool (M5) over a loaded skill set. No-op when there are no skills,
/// so the tool is only advertised when something can be loaded.
pub fn register_skill_tool(registry: &mut ToolRegistry, skills: &[harness_core::config::SkillDef]) {
if !skills.is_empty() {
registry.register(Arc::new(SkillTool::new(skills)));
}
}
+154
View File
@@ -0,0 +1,154 @@
//! The `skill` tool (M5): the system prompt advertises each skill's name + description; when
//! the model decides a skill is relevant it calls this tool with the skill name to pull the
//! full instructions on demand. See `docs/09-integrations.md`.
use std::collections::HashMap;
use async_trait::async_trait;
use harness_core::config::SkillDef;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
struct SkillParams {
/// The name of the skill to load, as advertised in the system prompt.
name: String,
}
/// Serves skill bodies by name. Built from the loaded skill set; if there are no skills the
/// caller simply doesn't register the tool.
pub struct SkillTool {
/// name → (description, body).
skills: HashMap<String, (String, String)>,
/// Sorted names, for a stable "unknown skill" hint.
names: Vec<String>,
description: String,
}
impl SkillTool {
pub fn new(skills: &[SkillDef]) -> Self {
let mut names: Vec<String> = skills.iter().map(|s| s.name.clone()).collect();
names.sort();
let map = skills
.iter()
.map(|s| (s.name.clone(), (s.description.clone(), s.body.clone())))
.collect();
let description = format!(
"Load the full instructions for a named skill before doing the related work. \
Available skills: {}.",
names.join(", ")
);
Self {
skills: map,
names,
description,
}
}
}
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"skill"
}
fn description(&self) -> &str {
&self.description
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(SkillParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
_ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: SkillParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
match self.skills.get(&params.name) {
Some((_description, body)) => Ok(ToolOutput::new(
format!("skill: {}", params.name),
body.clone(),
)),
None => Err(ToolError::Invalid(format!(
"unknown skill {:?}; available: {}",
params.name,
self.names.join(", ")
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::event::EventBus;
use harness_core::permission::{spawn_auto_approve, PermissionService};
use harness_core::tool::{MetadataSink, PermissionHandle};
use harness_core::types::SessionId;
use std::sync::{Arc, Mutex};
use tokio_util::sync::CancellationToken;
fn ctx() -> ToolCtx {
let bus = EventBus::new();
let service = Arc::new(PermissionService::new(bus.clone()));
spawn_auto_approve(bus, service.clone());
let (metadata, _rx) = MetadataSink::channel();
ToolCtx {
session_id: SessionId::new(),
message_id: harness_core::types::MessageId::new(),
call_id: "c1".into(),
data_dir: std::env::temp_dir(),
cwd: std::env::temp_dir(),
cancel: CancellationToken::new(),
ask: PermissionHandle::new(
service,
SessionId::new(),
Vec::new(),
Arc::new(Mutex::new(Vec::new())),
CancellationToken::new(),
),
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
fn sample() -> Vec<SkillDef> {
vec![SkillDef {
name: "formatter".into(),
description: "format code".into(),
body: "Run cargo fmt.".into(),
}]
}
#[tokio::test]
async fn returns_skill_body_by_name() {
let tool = SkillTool::new(&sample());
let out = tool
.execute(serde_json::json!({"name": "formatter"}), ctx())
.await
.unwrap();
assert_eq!(out.output, "Run cargo fmt.");
}
#[tokio::test]
async fn unknown_skill_is_an_input_error() {
let tool = SkillTool::new(&sample());
let err = tool
.execute(serde_json::json!({"name": "nope"}), ctx())
.await
.unwrap_err();
assert!(matches!(err, ToolError::Invalid(_)));
}
#[test]
fn description_lists_available_skills() {
let tool = SkillTool::new(&sample());
assert!(tool.description().contains("formatter"));
}
}