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