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#[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#[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(default)]
42pub struct BindingManifestEntry {
43 pub name: String,
45 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
88#[serde(default)]
89pub struct BindingManifest {
90 pub schema_version: u32,
91 pub bindings: Vec<BindingManifestEntry>,
92 #[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
180pub 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
211pub 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];