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: tool
293                .get("metadata")
294                .or_else(|| tool.get("_meta"))
295                .cloned()
296                .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
297        });
298    }
299    BindingManifest::new(entries, options.side_effect_ceiling)
300}
301
302fn tool_surface_entries(value: &Value) -> Vec<Value> {
303    match value {
304        Value::Array(items) => items.clone(),
305        Value::Object(map) => {
306            if let Some(Value::Array(items)) = map.get("tools") {
307                return items.clone();
308            }
309            if map.get("name").and_then(Value::as_str).is_some() {
310                return vec![value.clone()];
311            }
312            Vec::new()
313        }
314        _ => Vec::new(),
315    }
316}
317
318fn binding_source(tool: &Value) -> String {
319    if tool
320        .get("defer_loading")
321        .and_then(Value::as_bool)
322        .unwrap_or(false)
323    {
324        return "deferred".to_string();
325    }
326    if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
327        return executor.to_string();
328    }
329    if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
330        return "mcp_server".to_string();
331    }
332    if tool.get("function").is_some() {
333        return "provider_native".to_string();
334    }
335    "harn".to_string()
336}
337
338fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
339    let base = sanitize_binding_identifier(name);
340    if used.insert(base.clone()) {
341        return base;
342    }
343    for index in 2.. {
344        let candidate = format!("{base}_{index}");
345        if used.insert(candidate.clone()) {
346            return candidate;
347        }
348    }
349    unreachable!("unbounded identifier suffix search")
350}
351
352fn sanitize_binding_identifier(name: &str) -> String {
353    let mut out = String::new();
354    for (idx, ch) in name.chars().enumerate() {
355        if ch == '_' || ch.is_ascii_alphanumeric() {
356            if idx == 0 && ch.is_ascii_digit() {
357                out.push_str("tool_");
358            }
359            out.push(ch);
360        } else {
361            out.push('_');
362        }
363    }
364    while out.contains("__") {
365        out = out.replace("__", "_");
366    }
367    let out = out.trim_matches('_').to_string();
368    let out = if out.is_empty() {
369        "tool".to_string()
370    } else {
371        out
372    };
373    if HARN_KEYWORDS.contains(&out.as_str()) {
374        format!("tool_{out}")
375    } else {
376        out
377    }
378}
379
380const HARN_KEYWORDS: &[&str] = &[
381    "agent",
382    "as",
383    "await",
384    "break",
385    "catch",
386    "continue",
387    "defer",
388    "else",
389    "enum",
390    "false",
391    "fn",
392    "for",
393    "if",
394    "impl",
395    "import",
396    "in",
397    "interface",
398    "let",
399    "match",
400    "nil",
401    "pipeline",
402    "pub",
403    "return",
404    "skill",
405    "spawn",
406    "struct",
407    "throw",
408    "true",
409    "try",
410    "type",
411    "var",
412    "while",
413];