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