use super::store::load_pack;
use super::types::HarnessPack;
use crate::skills::SkillManifest;
use std::collections::BTreeSet;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct HarnessApplyResult {
pub packs: Vec<HarnessPack>,
pub max_auto_continue_turns: Option<u32>,
pub token_budget: Option<i64>,
pub allow_tools: Option<Vec<String>>,
pub harness_card: String,
}
pub fn effective_allow_tools_for_pack(pack: &HarnessPack) -> Option<Vec<String>> {
let node = pack.entry_node()?;
if node.allow_tools.is_empty() {
return None;
}
let mut tools = node.allow_tools.clone();
tools.sort();
tools.dedup();
Some(tools)
}
pub fn merge_allow_tools(lists: &[Vec<String>]) -> Option<Vec<String>> {
let non_empty: Vec<&Vec<String>> = lists.iter().filter(|l| !l.is_empty()).collect();
if non_empty.is_empty() {
return None;
}
let mut set: BTreeSet<String> = non_empty[0].iter().cloned().collect();
for list in non_empty.iter().skip(1) {
set.retain(|t| list.iter().any(|a| a == t));
}
let mut out: Vec<String> = set.into_iter().collect();
out.sort();
Some(out)
}
pub fn apply_harness_for_skills(
data_dir: &Path,
active_skills: &[SkillManifest],
) -> HarnessApplyResult {
let mut result = HarnessApplyResult::default();
let mut allow_lists: Vec<Vec<String>> = Vec::new();
let mut card_sections: Vec<String> = Vec::new();
for skill in active_skills {
if skill.harness && !skill.allow_tools.is_empty() {
allow_lists.push(skill.allow_tools.clone());
}
let Ok(Some(pack)) = load_pack(data_dir, &skill.id) else {
continue;
};
if pack.loop_spec.max_turns > 0 {
result.max_auto_continue_turns = Some(
result
.max_auto_continue_turns
.map(|m| m.min(pack.loop_spec.max_turns))
.unwrap_or(pack.loop_spec.max_turns),
);
}
if let Some(budget) = pack.loop_spec.token_budget {
if budget > 0 {
result.token_budget =
Some(result.token_budget.map(|b| b.min(budget)).unwrap_or(budget));
}
}
if let Some(tools) = effective_allow_tools_for_pack(&pack) {
allow_lists.push(tools);
}
card_sections.push(render_harness_card_section(&pack));
result.packs.push(pack);
}
result.allow_tools = merge_allow_tools(&allow_lists);
if !card_sections.is_empty() {
result.harness_card = format!(
"=== Active Harness Packs ===\n{}",
card_sections.join("\n\n")
);
}
result
}
fn render_harness_card_section(pack: &HarnessPack) -> String {
let mut lines = Vec::new();
lines.push(format!("## harness `{}`", pack.id));
lines.push(format!("- path: {}", pack.root.display()));
lines.push(format!("- loop.max_turns: {}", pack.loop_spec.max_turns));
if let Some(b) = pack.loop_spec.token_budget {
lines.push(format!("- loop.token_budget: {b}"));
}
lines.push(format!("- loop.stop: [{}]", pack.loop_spec.stop.join(", ")));
if let Some(graph) = &pack.graph {
lines.push(format!("- graph.entry: {}", graph.entry));
let node_ids: Vec<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
lines.push(format!("- graph.nodes: [{}]", node_ids.join(", ")));
if let Some(entry) = pack.entry_node() {
if !entry.allow_tools.is_empty() {
lines.push(format!(
"- graph.entry_allow_tools: [{}]",
entry.allow_tools.join(", ")
));
}
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness_pack::capability::inventory_from_tool_names;
use crate::harness_pack::materialize::{MaterializeOptions, materialize_from_skill};
use crate::skills::{SkillSource, SkillWriteScope};
use std::path::PathBuf;
fn skill(id: &str, allow: &[&str]) -> SkillManifest {
SkillManifest {
id: id.into(),
name: id.into(),
description: None,
version: None,
author: None,
tags: vec![],
requires: vec![],
allow_tools: allow.iter().map(|s| (*s).to_string()).collect(),
deny_tools: vec![],
harness: false,
pool: None,
path: PathBuf::from("x"),
instructions: "do the thing".into(),
source: SkillSource::Store,
scope: SkillWriteScope::User,
}
}
#[test]
fn soft_graph_entry_allow_tools() {
let dir = tempfile::tempdir().unwrap();
let inv = inventory_from_tool_names(
[
"search",
"read_file",
"edit",
"write_file",
"bash",
"tool_search",
],
["browser"],
true,
true,
50,
None::<String>,
None::<String>,
None::<String>,
);
let s = skill("design-loop", &[]);
materialize_from_skill(dir.path(), &s, &inv, MaterializeOptions::default()).unwrap();
let pack = load_pack(dir.path(), "design-loop").unwrap().unwrap();
let tools = effective_allow_tools_for_pack(&pack).unwrap();
assert!(tools.contains(&"search".to_string()) || tools.contains(&"read_file".to_string()));
assert!(tools.iter().any(|t| t == "search" || t == "read_file"));
}
#[test]
fn apply_merges_loop_caps_and_card() {
let dir = tempfile::tempdir().unwrap();
let inv = inventory_from_tool_names(
[
"search",
"read_file",
"edit",
"write_file",
"bash",
"tool_search",
"plan",
],
["browser"],
true,
true,
50,
None::<String>,
None::<String>,
None::<String>,
);
let s = skill("design-loop", &[]);
let mut opts = MaterializeOptions::default();
opts.default_max_turns = Some(7);
opts.default_token_budget = Some(20_000);
materialize_from_skill(dir.path(), &s, &inv, opts).unwrap();
let active = vec![skill("design-loop", &[])];
let applied = apply_harness_for_skills(dir.path(), &active);
assert_eq!(applied.packs.len(), 1);
assert_eq!(applied.max_auto_continue_turns, Some(7));
assert_eq!(applied.token_budget, Some(20_000));
assert!(!applied.harness_card.is_empty());
assert!(applied.harness_card.contains("design-loop"));
assert!(applied.allow_tools.is_some());
}
#[test]
fn merge_allow_tools_intersects() {
let a = vec!["a".into(), "b".into(), "c".into()];
let b = vec!["b".into(), "c".into(), "d".into()];
let m = merge_allow_tools(&[a, b]).unwrap();
assert_eq!(m, vec!["b".to_string(), "c".to_string()]);
}
}