Skip to main content

mur_common/skill/
resolve.rs

1use crate::skill::inventory::McpInventory;
2use crate::skill::manifest::ProcedureStep;
3use crate::skill::mcp::{McpRequirement, SkillCapability};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Resolution {
7    /// Step had a literal `tool` and no `intent` — pre-M6b path.
8    Literal { tool: String },
9    /// `tool_hint` matched an inventory entry.
10    Hint { tool: String },
11    /// An `mcp_requirements` glob matched at least one inventory tool.
12    IntentMatch {
13        tool: String,
14        capability: SkillCapability,
15    },
16    /// No glob match, but the requirement's `fallback` exists in the inventory.
17    Fallback {
18        tool: String,
19        capability: SkillCapability,
20    },
21    /// No usable tool.
22    Unresolved { reason: String },
23}
24
25impl Resolution {
26    pub fn picked_tool(&self) -> Option<&str> {
27        match self {
28            Resolution::Literal { tool }
29            | Resolution::Hint { tool }
30            | Resolution::IntentMatch { tool, .. }
31            | Resolution::Fallback { tool, .. } => Some(tool),
32            Resolution::Unresolved { .. } => None,
33        }
34    }
35
36    pub fn source_tag(&self) -> &'static str {
37        match self {
38            Resolution::Literal { .. } => "literal",
39            Resolution::Hint { .. } => "hint",
40            Resolution::IntentMatch { .. } => "intent_match",
41            Resolution::Fallback { .. } => "fallback",
42            Resolution::Unresolved { .. } => "unresolved",
43        }
44    }
45}
46
47/// Resolve a single procedure step against the agent's MCP inventory.
48///
49/// Decision tree:
50/// 1. `tool` set + `intent` unset → `Literal(tool)` (pre-M6b behaviour).
51/// 2. `tool_hint` set + inventory match → `Hint(tool)`.
52/// 3. `intent` set + `mcp_requirements` glob matches an inventory tool →
53///    `IntentMatch(shortest tool, capability)`.
54/// 4. `intent` set + no glob match but `fallback` in inventory → `Fallback(fallback)`.
55/// 5. Otherwise → `Unresolved { reason }`.
56pub fn resolve_step(
57    step: &ProcedureStep,
58    requirements: &[McpRequirement],
59    inventory: &McpInventory,
60) -> Resolution {
61    // Rule 1 — literal tool, no intent (pre-M6b path).
62    if step.intent.is_none() {
63        if let Some(t) = step.tool.as_deref() {
64            return Resolution::Literal {
65                tool: t.to_string(),
66            };
67        }
68        return Resolution::Unresolved {
69            reason: "step has neither tool nor intent".into(),
70        };
71    }
72
73    // Rule 2 — tool_hint match.
74    if let Some(hint) = step.tool_hint.as_deref()
75        && let Some(t) = match_in_inventory(hint, inventory)
76    {
77        return Resolution::Hint { tool: t };
78    }
79
80    // Rule 3 — intent_match via mcp_requirements globs.
81    let mut best: Option<(String, SkillCapability)> = None;
82    for req in requirements {
83        let Ok(glob) = globset::Glob::new(&req.tool_pattern) else {
84            continue;
85        };
86        let matcher = glob.compile_matcher();
87        let mut candidates: Vec<&str> = inventory.iter().filter(|t| matcher.is_match(t)).collect();
88        if candidates.is_empty() {
89            continue;
90        }
91        // Shortest name wins; lexicographically smallest on ties.
92        candidates.sort_by(|a, b| a.len().cmp(&b.len()).then(a.cmp(b)));
93        let pick = candidates[0].to_string();
94        best = Some((pick, req.capability));
95        break;
96    }
97    if let Some((tool, cap)) = best {
98        return Resolution::IntentMatch {
99            tool,
100            capability: cap,
101        };
102    }
103
104    // Rule 4 — fallback.
105    for req in requirements {
106        if req.fallback.is_empty() {
107            continue;
108        }
109        if inventory.contains(&req.fallback) {
110            return Resolution::Fallback {
111                tool: req.fallback.clone(),
112                capability: req.capability,
113            };
114        }
115    }
116
117    // Rule 5 — unresolved.
118    Resolution::Unresolved {
119        reason: format!(
120            "intent '{}' has no matching tool in inventory ({} tools, {} requirements)",
121            step.intent.as_deref().unwrap_or(""),
122            inventory.iter().count(),
123            requirements.len(),
124        ),
125    }
126}
127
128fn match_in_inventory(pattern: &str, inv: &McpInventory) -> Option<String> {
129    if pattern.contains('*') {
130        let Ok(g) = globset::Glob::new(pattern) else {
131            return None;
132        };
133        let m = g.compile_matcher();
134        let mut hits: Vec<&str> = inv.iter().filter(|t| m.is_match(t)).collect();
135        hits.sort_by(|a, b| a.len().cmp(&b.len()).then(a.cmp(b)));
136        hits.first().map(|s| s.to_string())
137    } else {
138        inv.contains(pattern).then(|| pattern.to_string())
139    }
140}