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_lexer::KEYWORDS.contains(&out.as_str()) {
408        format!("tool_{out}")
409    } else {
410        out
411    }
412}