Skip to main content

harn_vm/composition/
manifest.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
8
9pub const BINDING_MANIFEST_SCHEMA_VERSION: u32 = 1;
10
11/// Policy disposition for a binding projected into a composition manifest.
12#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BindingPolicyDisposition {
15    Allowed,
16    Gated,
17    Denied,
18}
19
20/// Policy metadata attached to a manifest binding.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct BindingPolicyStatus {
24    pub disposition: BindingPolicyDisposition,
25    pub reason: Option<String>,
26}
27
28impl Default for BindingPolicyStatus {
29    fn default() -> Self {
30        Self {
31            disposition: BindingPolicyDisposition::Allowed,
32            reason: None,
33        }
34    }
35}
36
37/// Prompt-visible description of one callable binding.
38#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
39#[serde(default)]
40pub struct BindingManifestEntry {
41    /// Canonical runtime tool name.
42    pub name: String,
43    /// Harn identifier injected into the composition snippet.
44    pub binding: String,
45    pub namespace: Option<String>,
46    pub description: Option<String>,
47    pub input_schema: Value,
48    pub output_schema: Option<Value>,
49    pub annotations: ToolAnnotations,
50    pub side_effect_level: SideEffectLevel,
51    pub capabilities: BTreeMap<String, Vec<String>>,
52    pub path_args: Vec<String>,
53    pub examples: Vec<Value>,
54    /// `harn`, `host_bridge`, `mcp_server`, `provider_native`, `deferred`,
55    /// or another forward-compatible source label.
56    pub source: String,
57    pub deferred: bool,
58    pub policy: BindingPolicyStatus,
59    pub metadata: Value,
60}
61
62impl Default for BindingManifestEntry {
63    fn default() -> Self {
64        Self {
65            name: String::new(),
66            binding: String::new(),
67            namespace: None,
68            description: None,
69            input_schema: serde_json::json!({"type": "object"}),
70            output_schema: None,
71            annotations: ToolAnnotations::default(),
72            side_effect_level: SideEffectLevel::None,
73            capabilities: BTreeMap::new(),
74            path_args: Vec::new(),
75            examples: Vec::new(),
76            source: "harn".to_string(),
77            deferred: false,
78            policy: BindingPolicyStatus::default(),
79            metadata: Value::Object(serde_json::Map::new()),
80        }
81    }
82}
83
84/// Stable prompt-visible manifest for a composition run.
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
86#[serde(default)]
87pub struct BindingManifest {
88    pub schema_version: u32,
89    pub bindings: Vec<BindingManifestEntry>,
90    pub side_effect_ceiling: SideEffectLevel,
91    pub metadata: Value,
92}
93
94impl Default for BindingManifest {
95    fn default() -> Self {
96        Self {
97            schema_version: BINDING_MANIFEST_SCHEMA_VERSION,
98            bindings: Vec::new(),
99            side_effect_ceiling: SideEffectLevel::ReadOnly,
100            metadata: Value::Object(serde_json::Map::new()),
101        }
102    }
103}
104
105impl BindingManifest {
106    pub fn new(mut bindings: Vec<BindingManifestEntry>, ceiling: SideEffectLevel) -> Self {
107        bindings.sort_by(|a, b| a.binding.cmp(&b.binding).then(a.name.cmp(&b.name)));
108        Self {
109            bindings,
110            side_effect_ceiling: ceiling,
111            ..Self::default()
112        }
113    }
114
115    pub fn to_value(&self) -> Value {
116        serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({"bindings": []}))
117    }
118
119    pub fn to_compact_value(&self) -> Value {
120        Value::Object(serde_json::Map::from_iter([
121            (
122                "schema_version".to_string(),
123                Value::Number(self.schema_version.into()),
124            ),
125            (
126                "side_effect_ceiling".to_string(),
127                serde_json::json!(self.side_effect_ceiling),
128            ),
129            (
130                "bindings".to_string(),
131                Value::Array(
132                    self.bindings
133                        .iter()
134                        .map(|binding| {
135                            serde_json::json!({
136                                "name": binding.name,
137                                "binding": binding.binding,
138                                "namespace": binding.namespace,
139                                "description": binding.description,
140                                "side_effect_level": binding.side_effect_level,
141                                "policy": binding.policy,
142                                "source": binding.source,
143                                "deferred": binding.deferred,
144                                "examples": binding.examples,
145                            })
146                        })
147                        .collect(),
148                ),
149            ),
150        ]))
151    }
152
153    pub fn hash(&self) -> Result<String, serde_json::Error> {
154        binding_manifest_hash(&self.to_value())
155    }
156
157    pub fn find_by_binding(&self, binding: &str) -> Option<&BindingManifestEntry> {
158        self.bindings.iter().find(|entry| entry.binding == binding)
159    }
160
161    pub fn find_by_name(&self, name: &str) -> Option<&BindingManifestEntry> {
162        self.bindings.iter().find(|entry| entry.name == name)
163    }
164}
165
166/// Stable digest for a binding manifest value. Producers should build
167/// manifests with deterministic object key order before hashing.
168pub fn binding_manifest_hash(manifest: &Value) -> Result<String, serde_json::Error> {
169    let canonical = serde_json::to_vec(manifest)?;
170    let mut hasher = Sha256::new();
171    hasher.update(b"harn.composition.binding_manifest.v1\0");
172    hasher.update(&canonical);
173    Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct BindingManifestOptions {
178    pub side_effect_ceiling: SideEffectLevel,
179    pub include_denied: bool,
180    pub denied_tools: BTreeSet<String>,
181    pub gated_tools: BTreeSet<String>,
182}
183
184impl Default for BindingManifestOptions {
185    fn default() -> Self {
186        Self {
187            side_effect_ceiling: SideEffectLevel::ReadOnly,
188            include_denied: false,
189            denied_tools: BTreeSet::new(),
190            gated_tools: BTreeSet::new(),
191        }
192    }
193}
194
195/// Build a binding manifest from a Harn tool registry, MCP `tools/list`
196/// payload, or provider-native tool array.
197pub fn binding_manifest_from_tool_surface(
198    tools: &Value,
199    options: BindingManifestOptions,
200) -> BindingManifest {
201    let mut used_bindings = BTreeSet::new();
202    let annotations_by_name = crate::tool_surface::tool_annotations_from_spec(tools);
203    let mut entries = Vec::new();
204    for tool in tool_surface_entries(tools) {
205        let Some(name) = tool
206            .get("name")
207            .and_then(Value::as_str)
208            .filter(|s| !s.is_empty())
209        else {
210            continue;
211        };
212        let annotations = tool
213            .get("annotations")
214            .cloned()
215            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
216            .or_else(|| annotations_by_name.get(name).cloned())
217            .unwrap_or_default();
218        let side_effect_level = annotations.side_effect_level;
219        let mut policy = BindingPolicyStatus::default();
220        if options.denied_tools.contains(name) {
221            policy.disposition = BindingPolicyDisposition::Denied;
222            policy.reason = Some("denied by active tool policy".to_string());
223        } else if side_effect_level.rank() > options.side_effect_ceiling.rank() {
224            policy.disposition = BindingPolicyDisposition::Denied;
225            policy.reason = Some(format!(
226                "requires side-effect level '{}' above composition ceiling '{}'",
227                side_effect_level.as_str(),
228                options.side_effect_ceiling.as_str()
229            ));
230        } else if options.gated_tools.contains(name) {
231            policy.disposition = BindingPolicyDisposition::Gated;
232            policy.reason = Some("requires host approval before dispatch".to_string());
233        }
234        if !options.include_denied && policy.disposition == BindingPolicyDisposition::Denied {
235            continue;
236        }
237        let binding = unique_binding_identifier(name, &mut used_bindings);
238        let source = binding_source(&tool);
239        let deferred = tool
240            .get("defer_loading")
241            .and_then(Value::as_bool)
242            .or_else(|| {
243                tool.get("function")
244                    .and_then(|function| function.get("defer_loading"))
245                    .and_then(Value::as_bool)
246            })
247            .unwrap_or(source == "deferred");
248        let input_schema = tool
249            .get("inputSchema")
250            .or_else(|| tool.get("input_schema"))
251            .or_else(|| tool.get("parameters"))
252            .or_else(|| tool.get("function").and_then(|f| f.get("parameters")))
253            .cloned()
254            .unwrap_or_else(|| serde_json::json!({"type": "object"}));
255        let output_schema = tool
256            .get("outputSchema")
257            .or_else(|| tool.get("output_schema"))
258            .or_else(|| tool.get("returns"))
259            .or_else(|| {
260                tool.get("function")
261                    .and_then(|f| f.get("x-harn-output-schema"))
262            })
263            .cloned();
264        let examples = tool
265            .get("examples")
266            .and_then(Value::as_array)
267            .cloned()
268            .unwrap_or_default();
269        entries.push(BindingManifestEntry {
270            name: name.to_string(),
271            binding,
272            namespace: tool
273                .get("namespace")
274                .and_then(Value::as_str)
275                .map(ToOwned::to_owned),
276            description: tool
277                .get("description")
278                .or_else(|| tool.get("function").and_then(|f| f.get("description")))
279                .and_then(Value::as_str)
280                .filter(|s| !s.is_empty())
281                .map(ToOwned::to_owned),
282            input_schema,
283            output_schema,
284            side_effect_level,
285            capabilities: annotations.capabilities.clone(),
286            path_args: annotations.arg_schema.path_params.clone(),
287            annotations,
288            examples,
289            source,
290            deferred,
291            policy,
292            metadata: binding_metadata(&tool),
293        });
294    }
295    BindingManifest::new(entries, options.side_effect_ceiling)
296}
297
298fn tool_surface_entries(value: &Value) -> Vec<Value> {
299    match value {
300        Value::Array(items) => items.clone(),
301        Value::Object(map) => {
302            if let Some(Value::Array(items)) = map.get("tools") {
303                return items.clone();
304            }
305            if map.get("name").and_then(Value::as_str).is_some() {
306                return vec![value.clone()];
307            }
308            Vec::new()
309        }
310        _ => Vec::new(),
311    }
312}
313
314fn binding_source(tool: &Value) -> String {
315    if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
316        return executor.to_string();
317    }
318    if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
319        return "mcp_server".to_string();
320    }
321    if tool.get("function").is_some() {
322        return "provider_native".to_string();
323    }
324    if tool
325        .get("defer_loading")
326        .and_then(Value::as_bool)
327        .unwrap_or(false)
328    {
329        return "deferred".to_string();
330    }
331    "harn".to_string()
332}
333
334fn binding_metadata(tool: &Value) -> Value {
335    let mut metadata = tool
336        .get("metadata")
337        .or_else(|| tool.get("_meta"))
338        .and_then(Value::as_object)
339        .cloned()
340        .unwrap_or_default();
341    for key in ["_mcp_server", "mcp_server", "_mcp_tool_name"] {
342        if let Some(value) = tool.get(key) {
343            metadata
344                .entry(key.to_string())
345                .or_insert_with(|| value.clone());
346        }
347    }
348    Value::Object(metadata)
349}
350
351fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
352    let base = sanitize_binding_identifier(name);
353    if used.insert(base.clone()) {
354        return base;
355    }
356    for index in 2.. {
357        let candidate = format!("{base}_{index}");
358        if used.insert(candidate.clone()) {
359            return candidate;
360        }
361    }
362    unreachable!("unbounded identifier suffix search")
363}
364
365fn sanitize_binding_identifier(name: &str) -> String {
366    let mut out = String::new();
367    for (idx, ch) in name.chars().enumerate() {
368        if ch == '_' || ch.is_ascii_alphanumeric() {
369            if idx == 0 && ch.is_ascii_digit() {
370                out.push_str("tool_");
371            }
372            out.push(ch);
373        } else {
374            out.push('_');
375        }
376    }
377    while out.contains("__") {
378        out = out.replace("__", "_");
379    }
380    let out = out.trim_matches('_').to_string();
381    let out = if out.is_empty() {
382        "tool".to_string()
383    } else {
384        out
385    };
386    if HARN_KEYWORDS.contains(&out.as_str()) {
387        format!("tool_{out}")
388    } else {
389        out
390    }
391}
392
393const HARN_KEYWORDS: &[&str] = &[
394    "agent",
395    "as",
396    "await",
397    "break",
398    "catch",
399    "continue",
400    "defer",
401    "else",
402    "enum",
403    "false",
404    "fn",
405    "for",
406    "if",
407    "impl",
408    "import",
409    "in",
410    "interface",
411    "let",
412    "match",
413    "nil",
414    "pipeline",
415    "pub",
416    "return",
417    "skill",
418    "spawn",
419    "struct",
420    "throw",
421    "true",
422    "try",
423    "type",
424    "var",
425    "while",
426];