Skip to main content

navi_core/harness_pack/
materialize.rs

1//! Deterministic materialize: skill instructions → harness pack files.
2
3use super::capability::{CapabilityInventory, capability_card, filter_tools_to_inventory};
4use super::store::write_pack;
5use super::types::{
6    GraphEdge, GraphNode, GraphSpec, HarnessPack, LoopSpec, VerifierKind, VerifierSpec,
7};
8use crate::skills::{SkillManifest, SkillWriteResult};
9use anyhow::Result;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13/// Options controlling deterministic pack generation.
14#[derive(Debug, Clone, Default)]
15pub struct MaterializeOptions {
16    /// Override pack id (defaults to skill id).
17    pub id_override: Option<String>,
18    /// Default max_turns when not inferred.
19    pub default_max_turns: Option<u32>,
20    /// Default token budget when not inferred.
21    pub default_token_budget: Option<i64>,
22    /// Required skills that form the harness graph nodes (id -> manifest).
23    pub required_skills: HashMap<String, SkillManifest>,
24}
25
26/// Materialize a harness pack from a skill and capability inventory.
27///
28/// Tool names in the resulting graph are filtered to `inventory`. Unknown tools
29/// are never invented: only names present in the inventory may appear.
30pub fn materialize_from_skill(
31    data_dir: &Path,
32    skill: &SkillManifest,
33    inventory: &CapabilityInventory,
34    options: MaterializeOptions,
35) -> Result<HarnessPack> {
36    let id = options
37        .id_override
38        .clone()
39        .filter(|s| !s.trim().is_empty())
40        .unwrap_or_else(|| skill.id.clone());
41
42    let max_turns = options.default_max_turns.unwrap_or(15);
43    let token_budget = options.default_token_budget;
44
45    // Build ordered sequence of skills: required skills first, then the main skill.
46    let mut ordered: Vec<&SkillManifest> = Vec::new();
47    for req_id in &skill.requires {
48        if let Some(req) = options.required_skills.get(req_id) {
49            ordered.push(req);
50        }
51    }
52    ordered.push(skill);
53
54    // If we have a multi-skill harness, create a node per skill in the chain.
55    let graph = if ordered.len() > 1 {
56        build_multi_skill_graph(ordered, inventory)
57    } else {
58        build_single_skill_graph(skill, inventory)
59    };
60
61    let mut verifiers = Vec::new();
62    if inventory.all_tools.iter().any(|t| t == "bash") {
63        verifiers.push(VerifierSpec {
64            id: "default_verify".into(),
65            kind: VerifierKind::Bash,
66            recipe: "echo 'run project tests via bash when appropriate'".into(),
67        });
68    }
69    if inventory.browser_available {
70        verifiers.push(VerifierSpec {
71            id: "browser_smoke".into(),
72            kind: VerifierKind::Browser,
73            recipe: "browser open/goto/screenshot against local preview when available".into(),
74        });
75    }
76
77    let loop_spec = LoopSpec {
78        id: id.clone(),
79        max_turns,
80        token_budget,
81        stop: vec![
82            "goal.complete".into(),
83            "goal.blocked".into(),
84            "budget".into(),
85            "max_turns".into(),
86        ],
87    };
88
89    let skill_md = format!(
90        "---\nname: {}\nid: {}\ndescription: {}\n---\n\n{}\n",
91        skill.name,
92        skill.id,
93        skill.description.as_deref().unwrap_or(""),
94        skill.instructions.trim()
95    );
96
97    let mut cap = capability_card(inventory);
98    cap.push_str("\n\n## Materialize notes\n");
99    cap.push_str(&format!(
100        "- source_skill: {}\n- graph_entry: {}\n- nodes: {}\n",
101        skill.id,
102        graph.entry,
103        graph
104            .nodes
105            .iter()
106            .map(|n| n.id.as_str())
107            .collect::<Vec<_>>()
108            .join(", ")
109    ));
110    let gaps: Vec<&str> = skill
111        .allow_tools
112        .iter()
113        .filter(|t| !inventory.all_tools.iter().any(|a| a == *t))
114        .map(|s| s.as_str())
115        .collect();
116    if !gaps.is_empty() {
117        cap.push_str(&format!(
118            "- filtered_unknown_tools: [{}]\n",
119            gaps.join(", ")
120        ));
121    }
122    if !skill.requires.is_empty() {
123        cap.push_str(&format!(
124            "- required_skills: [{}]\n",
125            skill.requires.join(", ")
126        ));
127        let missing: Vec<&str> = skill
128            .requires
129            .iter()
130            .filter(|r| !options.required_skills.contains_key(*r))
131            .map(|s| s.as_str())
132            .collect();
133        if !missing.is_empty() {
134            cap.push_str(&format!(
135                "- missing_required_skills: [{}]\n",
136                missing.join(", ")
137            ));
138        }
139    }
140
141    let pack = HarnessPack {
142        id: id.clone(),
143        root: PathBuf::new(),
144        loop_spec,
145        graph: Some(graph),
146        verifiers,
147        capability_md: Some(cap),
148        skill_md: Some(skill_md),
149    };
150
151    let root = write_pack(data_dir, &pack)?;
152    let mut out = pack;
153    out.root = root;
154    Ok(out)
155}
156
157/// Best-effort materialize after a skill is saved. Returns the pack path on success.
158///
159/// Uses a conservative default capability inventory so this can run inside the
160/// `skill_save` tool without access to a live executor registry.
161pub fn materialize_after_save(
162    data_dir: &Path,
163    result: &SkillWriteResult,
164    required_skills: &[SkillManifest],
165) -> Result<Option<PathBuf>> {
166    if !result.skill.harness && result.skill.requires.is_empty() {
167        return Ok(None);
168    }
169
170    let inv = default_capability_inventory();
171    let mut opts = MaterializeOptions::default();
172    for req in required_skills {
173        opts.required_skills.insert(req.id.clone(), req.clone());
174    }
175    let pack = materialize_from_skill(data_dir, &result.skill, &inv, opts)?;
176    Ok(Some(pack.root))
177}
178
179fn default_capability_inventory() -> CapabilityInventory {
180    use crate::harness_pack::capability::inventory_from_tool_names;
181    inventory_from_tool_names(
182        [
183            "search",
184            "read_file",
185            "edit",
186            "write_file",
187            "bash",
188            "plan",
189            "question",
190            "tool_search",
191            "memory",
192            "set_session_title",
193            "get_goal",
194            "create_goal",
195            "update_goal",
196            "skill_save",
197            "skill_list",
198            "skill_get",
199            "load_skill",
200        ],
201        [
202            "browser",
203            "code",
204            "code_edit",
205            "code_exec",
206            "repo_explore",
207            "subagent",
208            "workflow",
209            "package_manager",
210            "ast_search",
211            "symbol_goto",
212            "view_image",
213            "apply_patch",
214            "sandbox",
215            "analyze_attachment",
216        ],
217        true,
218        true,
219        50,
220        Vec::<String>::new(),
221        Vec::<String>::new(),
222        Vec::<String>::new(),
223    )
224}
225
226fn build_multi_skill_graph(
227    ordered: Vec<&SkillManifest>,
228    inventory: &CapabilityInventory,
229) -> GraphSpec {
230    let mut nodes = Vec::new();
231    let mut edges = Vec::new();
232    let entry = ordered[0].id.clone();
233
234    for (i, skill) in ordered.iter().enumerate() {
235        let role = if i == ordered.len() - 1 {
236            "final".into()
237        } else if skill
238            .allow_tools
239            .iter()
240            .any(|t| t == "write_file" || t == "edit" || t == "bash")
241        {
242            "write".into()
243        } else {
244            "read_only".into()
245        };
246        let tools = if skill.allow_tools.is_empty() {
247            default_core_tools(inventory)
248        } else {
249            filter_tools_to_inventory(&skill.allow_tools, inventory)
250        };
251        nodes.push(GraphNode {
252            id: skill.id.clone(),
253            role,
254            allow_tools: tools,
255            verifiers: vec![],
256        });
257        if i > 0 {
258            edges.push(GraphEdge {
259                from: ordered[i - 1].id.clone(),
260                to: skill.id.clone(),
261                when: None,
262            });
263        }
264    }
265
266    GraphSpec {
267        entry,
268        nodes,
269        edges,
270    }
271}
272
273fn build_single_skill_graph(skill: &SkillManifest, inventory: &CapabilityInventory) -> GraphSpec {
274    let explore_tools = filter_tools_to_inventory(
275        &[
276            "search".into(),
277            "read_file".into(),
278            "tool_search".into(),
279            "browser".into(),
280            "repo_explore".into(),
281            "code".into(),
282        ],
283        inventory,
284    );
285    let write_tools = filter_tools_to_inventory(
286        &[
287            "search".into(),
288            "read_file".into(),
289            "edit".into(),
290            "write_file".into(),
291            "bash".into(),
292            "plan".into(),
293            "tool_search".into(),
294        ],
295        inventory,
296    );
297    let verify_tools = filter_tools_to_inventory(
298        &[
299            "bash".into(),
300            "browser".into(),
301            "read_file".into(),
302            "search".into(),
303        ],
304        inventory,
305    );
306
307    let mut nodes = Vec::new();
308    let mut edges = Vec::new();
309    let entry;
310
311    if !explore_tools.is_empty() && !write_tools.is_empty() {
312        entry = "explore".into();
313        nodes.push(GraphNode {
314            id: "explore".into(),
315            role: "read_only".into(),
316            allow_tools: explore_tools,
317            verifiers: vec![],
318        });
319        nodes.push(GraphNode {
320            id: "implement".into(),
321            role: "write".into(),
322            allow_tools: write_tools,
323            verifiers: vec![],
324        });
325        edges.push(GraphEdge {
326            from: "explore".into(),
327            to: "implement".into(),
328            when: None,
329        });
330        if !verify_tools.is_empty() {
331            nodes.push(GraphNode {
332                id: "verify".into(),
333                role: "verify".into(),
334                allow_tools: verify_tools.clone(),
335                verifiers: vec!["default_verify".into()],
336            });
337            edges.push(GraphEdge {
338                from: "implement".into(),
339                to: "verify".into(),
340                when: None,
341            });
342            edges.push(GraphEdge {
343                from: "verify".into(),
344                to: "implement".into(),
345                when: Some("verify.failed".into()),
346            });
347        }
348    } else {
349        entry = "main".into();
350        nodes.push(GraphNode {
351            id: "main".into(),
352            role: "default".into(),
353            allow_tools: if skill.allow_tools.is_empty() {
354                default_core_tools(inventory)
355            } else {
356                filter_tools_to_inventory(&skill.allow_tools, inventory)
357            },
358            verifiers: vec![],
359        });
360    }
361
362    GraphSpec {
363        entry,
364        nodes,
365        edges,
366    }
367}
368
369fn default_core_tools(inventory: &CapabilityInventory) -> Vec<String> {
370    filter_tools_to_inventory(
371        &[
372            "search".into(),
373            "read_file".into(),
374            "edit".into(),
375            "write_file".into(),
376            "bash".into(),
377            "plan".into(),
378            "question".into(),
379            "tool_search".into(),
380            "memory".into(),
381        ],
382        inventory,
383    )
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::harness_pack::capability::inventory_from_tool_names;
390    use crate::harness_pack::store::load_pack;
391    use crate::skills::{SkillSource, SkillWriteScope};
392    use std::path::PathBuf;
393
394    fn fixture_skill(allow: &[&str]) -> SkillManifest {
395        SkillManifest {
396            id: "design-loop".into(),
397            name: "Design Loop".into(),
398            description: Some("Preview review harness".into()),
399            version: Some("1.0.0".into()),
400            author: None,
401            tags: vec!["harness".into()],
402            requires: vec![],
403            allow_tools: allow.iter().map(|s| (*s).to_string()).collect(),
404            deny_tools: vec![],
405            harness: true,
406            pool: None,
407            path: PathBuf::from("builtin:design-loop"),
408            instructions: "## Steps\n1. Explore\n2. Fix\n3. Verify preview\n".into(),
409            source: SkillSource::Builtin,
410            scope: SkillWriteScope::User,
411        }
412    }
413
414    #[test]
415    fn materialize_filters_unknown_tools_and_roundtrips() {
416        let dir = tempfile::tempdir().unwrap();
417        let inv = inventory_from_tool_names(
418            [
419                "search",
420                "read_file",
421                "edit",
422                "write_file",
423                "bash",
424                "plan",
425                "tool_search",
426            ],
427            ["browser", "subagent"],
428            true,
429            true,
430            50,
431            None::<String>,
432            None::<String>,
433            None::<String>,
434        );
435        let skill = fixture_skill(&["search", "read_file", "a11y_audit", "browser", "edit"]);
436        let pack = materialize_from_skill(dir.path(), &skill, &inv, MaterializeOptions::default())
437            .unwrap();
438        assert_eq!(pack.id, "design-loop");
439        assert!(pack.root.join("loop.toml").is_file());
440        assert!(pack.root.join("graph.toml").is_file());
441
442        // No invented a11y_audit
443        let graph = pack.graph.as_ref().unwrap();
444        for node in &graph.nodes {
445            assert!(
446                !node.allow_tools.iter().any(|t| t == "a11y_audit"),
447                "invented tool in {:?}",
448                node.allow_tools
449            );
450        }
451        assert!(
452            graph
453                .nodes
454                .iter()
455                .any(|n| n.allow_tools.iter().any(|t| t == "browser"))
456        );
457
458        let reloaded = load_pack(dir.path(), "design-loop").unwrap().unwrap();
459        assert_eq!(reloaded.loop_spec.max_turns, 15);
460        assert_eq!(reloaded.graph.as_ref().unwrap().entry, "explore");
461    }
462
463    #[test]
464    fn materialize_multi_skill_graph_from_requires() {
465        let dir = tempfile::tempdir().unwrap();
466        let inv = inventory_from_tool_names(
467            ["search", "read_file", "write_file", "edit", "bash"],
468            ["browser"],
469            true,
470            true,
471            50,
472            None::<String>,
473            None::<String>,
474            None::<String>,
475        );
476        let node_a = SkillManifest {
477            id: "analyst".into(),
478            name: "Analyst".into(),
479            description: None,
480            version: None,
481            author: None,
482            tags: vec![],
483            requires: vec![],
484            allow_tools: vec!["search".into(), "read_file".into()],
485            deny_tools: vec![],
486            harness: false,
487            pool: None,
488            path: PathBuf::from("store:analyst"),
489            instructions: "Analyze.".into(),
490            source: SkillSource::Store,
491            scope: SkillWriteScope::User,
492        };
493        let node_b = SkillManifest {
494            id: "implementer".into(),
495            name: "Implementer".into(),
496            description: None,
497            version: None,
498            author: None,
499            tags: vec![],
500            requires: vec![],
501            allow_tools: vec!["write_file".into(), "edit".into()],
502            deny_tools: vec![],
503            harness: false,
504            pool: None,
505            path: PathBuf::from("store:implementer"),
506            instructions: "Implement.".into(),
507            source: SkillSource::Store,
508            scope: SkillWriteScope::User,
509        };
510        let main = SkillManifest {
511            id: "design-loop".into(),
512            name: "Design Loop".into(),
513            description: Some("Loop".into()),
514            version: None,
515            author: None,
516            tags: vec!["harness".into()],
517            requires: vec!["analyst".into(), "implementer".into()],
518            allow_tools: vec!["read_file".into()],
519            deny_tools: vec![],
520            harness: true,
521            pool: None,
522            path: PathBuf::from("store:design-loop"),
523            instructions: "Run design loop.".into(),
524            source: SkillSource::Store,
525            scope: SkillWriteScope::User,
526        };
527        let mut opts = MaterializeOptions::default();
528        opts.required_skills.insert("analyst".into(), node_a);
529        opts.required_skills.insert("implementer".into(), node_b);
530        let pack = materialize_from_skill(dir.path(), &main, &inv, opts).unwrap();
531        let graph = pack.graph.as_ref().unwrap();
532        assert_eq!(graph.entry, "analyst");
533        assert_eq!(graph.nodes.len(), 3);
534        assert!(graph.nodes.iter().any(|n| n.id == "analyst"));
535        assert!(graph.nodes.iter().any(|n| n.id == "implementer"));
536        assert!(graph.nodes.iter().any(|n| n.id == "design-loop"));
537        let analyst = graph.nodes.iter().find(|n| n.id == "analyst").unwrap();
538        assert!(analyst.allow_tools.contains(&"search".into()));
539        assert!(!analyst.allow_tools.contains(&"write_file".into()));
540    }
541}