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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user