Skip to main content

harn_vm/
composition.rs

1//! Language-neutral executable tool-composition contract.
2//!
3//! A composition run is a tiny program over already-typed tool bindings. The
4//! runtime must expose it as a parent run with child tool operations, not as an
5//! opaque "execute code" blob, so policy, transcript, replay, and host approval
6//! surfaces can keep reasoning about each child call normally.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11use std::cell::RefCell;
12use std::collections::{BTreeMap, BTreeSet};
13use std::rc::Rc;
14use std::sync::Arc;
15
16use crate::agent_events::{AgentEvent, ToolCallErrorCategory, ToolCallStatus, ToolExecutor};
17use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
18use crate::value::{VmError, VmValue};
19use crate::vm::Vm;
20
21/// Stable failure taxonomy for a composition run. Tool-level failures stay on
22/// [`CompositionChildResult`]; this classifies why the parent composition
23/// itself failed or stopped.
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum CompositionFailureCategory {
27    /// The snippet language is unknown or not enabled by the current host.
28    UnsupportedLanguage,
29    /// The snippet or manifest did not validate before execution.
30    SchemaValidation,
31    /// Capability policy rejected the requested side-effect ceiling or a child
32    /// operation.
33    PolicyDenied,
34    /// A child binding returned an error.
35    ChildToolError,
36    /// The executor failed before it could attribute the error to a child call.
37    ExecutionError,
38    /// The run exceeded its time or step budget.
39    Timeout,
40    /// The host or caller cancelled the run.
41    Cancelled,
42    /// Fallback when a producer cannot classify the failure.
43    Unknown,
44}
45
46impl CompositionFailureCategory {
47    pub const ALL: [Self; 8] = [
48        Self::UnsupportedLanguage,
49        Self::SchemaValidation,
50        Self::PolicyDenied,
51        Self::ChildToolError,
52        Self::ExecutionError,
53        Self::Timeout,
54        Self::Cancelled,
55        Self::Unknown,
56    ];
57
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::UnsupportedLanguage => "unsupported_language",
61            Self::SchemaValidation => "schema_validation",
62            Self::PolicyDenied => "policy_denied",
63            Self::ChildToolError => "child_tool_error",
64            Self::ExecutionError => "execution_error",
65            Self::Timeout => "timeout",
66            Self::Cancelled => "cancelled",
67            Self::Unknown => "unknown",
68        }
69    }
70}
71
72/// Identity and policy envelope for one composition run.
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
74#[serde(default)]
75pub struct CompositionRunEnvelope {
76    /// Runtime-unique id used to correlate child calls and terminal events.
77    pub run_id: String,
78    /// Snippet frontend (`harn`, `typescript`, `javascript`, ...).
79    pub language: String,
80    /// `sha256:<hex>` digest over the language and snippet bytes.
81    pub snippet_hash: String,
82    /// `sha256:<hex>` digest over the binding manifest shown to the model.
83    pub binding_manifest_hash: String,
84    /// Highest side-effect level requested by the parent run.
85    pub requested_side_effect_ceiling: SideEffectLevel,
86    /// Captured stdout-like text emitted by the composition executor.
87    pub stdout: Option<String>,
88    /// Captured stderr-like text emitted by the composition executor.
89    pub stderr: Option<String>,
90    /// Artifact descriptors/handles emitted by the composition executor.
91    pub artifacts: Vec<Value>,
92    /// Structured result returned by the snippet.
93    pub result: Option<Value>,
94    /// Parent-run failure class, absent for successful finishes.
95    pub failure_category: Option<CompositionFailureCategory>,
96    /// Human-readable parent-run error, absent for successful finishes.
97    pub error: Option<String>,
98    /// Runtime wall-clock duration when a producer has measured it.
99    pub duration_ms: Option<u64>,
100    /// Forward-compatible producer metadata. Consumers must ignore unknown keys.
101    pub metadata: Value,
102}
103
104impl Default for CompositionRunEnvelope {
105    fn default() -> Self {
106        Self {
107            run_id: String::new(),
108            language: String::new(),
109            snippet_hash: String::new(),
110            binding_manifest_hash: String::new(),
111            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
112            stdout: None,
113            stderr: None,
114            artifacts: Vec::new(),
115            result: None,
116            failure_category: None,
117            error: None,
118            duration_ms: None,
119            metadata: Value::Object(serde_json::Map::new()),
120        }
121    }
122}
123
124impl CompositionRunEnvelope {
125    pub fn read_only(
126        run_id: impl Into<String>,
127        language: impl Into<String>,
128        snippet_hash: impl Into<String>,
129        binding_manifest_hash: impl Into<String>,
130    ) -> Self {
131        Self {
132            run_id: run_id.into(),
133            language: language.into(),
134            snippet_hash: snippet_hash.into(),
135            binding_manifest_hash: binding_manifest_hash.into(),
136            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
137            ..Self::default()
138        }
139    }
140}
141
142/// Child tool call made by a composition snippet. This is intentionally close
143/// to `AgentEvent::ToolCall`, but includes parent-run correlation and the
144/// policy/annotation context the composition executor used when deciding
145/// whether the call was allowed.
146#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
147#[serde(default)]
148pub struct CompositionChildCall {
149    pub run_id: String,
150    pub tool_call_id: String,
151    pub tool_name: String,
152    pub operation_index: u64,
153    pub annotations: Option<ToolAnnotations>,
154    pub requested_side_effect_level: SideEffectLevel,
155    pub policy_context: Value,
156    pub raw_input: Value,
157}
158
159impl Default for CompositionChildCall {
160    fn default() -> Self {
161        Self {
162            run_id: String::new(),
163            tool_call_id: String::new(),
164            tool_name: String::new(),
165            operation_index: 0,
166            annotations: None,
167            requested_side_effect_level: SideEffectLevel::None,
168            policy_context: Value::Object(serde_json::Map::new()),
169            raw_input: Value::Null,
170        }
171    }
172}
173
174/// Terminal or intermediate result for a child binding operation. Consumers
175/// should pair this with the corresponding [`CompositionChildCall`] to recover
176/// the policy annotations and requested side-effect level for the operation.
177#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
178#[serde(default)]
179pub struct CompositionChildResult {
180    pub run_id: String,
181    pub tool_call_id: String,
182    pub tool_name: String,
183    pub operation_index: u64,
184    pub status: ToolCallStatus,
185    pub raw_output: Option<Value>,
186    pub error: Option<String>,
187    pub error_category: Option<ToolCallErrorCategory>,
188    pub executor: Option<ToolExecutor>,
189    pub duration_ms: Option<u64>,
190    pub execution_duration_ms: Option<u64>,
191}
192
193impl Default for CompositionChildResult {
194    fn default() -> Self {
195        Self {
196            run_id: String::new(),
197            tool_call_id: String::new(),
198            tool_name: String::new(),
199            operation_index: 0,
200            status: ToolCallStatus::Pending,
201            raw_output: None,
202            error: None,
203            error_category: None,
204            executor: None,
205            duration_ms: None,
206            execution_duration_ms: None,
207        }
208    }
209}
210
211/// Stable digest for the prompt-visible snippet body.
212pub fn composition_snippet_hash(language: &str, snippet: &str) -> String {
213    let mut hasher = Sha256::new();
214    hasher.update(b"harn.composition.snippet.v1\0");
215    hasher.update(language.as_bytes());
216    hasher.update(b"\0");
217    hasher.update(snippet.as_bytes());
218    format!("sha256:{}", hex::encode(hasher.finalize()))
219}
220
221/// Stable digest for a binding manifest value. Producers should build
222/// manifests with deterministic object key order before hashing.
223pub fn binding_manifest_hash(manifest: &Value) -> Result<String, serde_json::Error> {
224    let canonical = serde_json::to_vec(manifest)?;
225    let mut hasher = Sha256::new();
226    hasher.update(b"harn.composition.binding_manifest.v1\0");
227    hasher.update(&canonical);
228    Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
229}
230
231pub const BINDING_MANIFEST_SCHEMA_VERSION: u32 = 1;
232pub const COMPOSITION_EXECUTION_SCHEMA_VERSION: u32 = 1;
233
234/// Policy disposition for a binding projected into a composition manifest.
235#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum BindingPolicyDisposition {
238    Allowed,
239    Gated,
240    Denied,
241}
242
243/// Policy metadata attached to a manifest binding.
244#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
245#[serde(default)]
246pub struct BindingPolicyStatus {
247    pub disposition: BindingPolicyDisposition,
248    pub reason: Option<String>,
249}
250
251impl Default for BindingPolicyStatus {
252    fn default() -> Self {
253        Self {
254            disposition: BindingPolicyDisposition::Allowed,
255            reason: None,
256        }
257    }
258}
259
260/// Prompt-visible description of one callable binding.
261#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
262#[serde(default)]
263pub struct BindingManifestEntry {
264    /// Canonical runtime tool name.
265    pub name: String,
266    /// Harn identifier injected into the composition snippet.
267    pub binding: String,
268    pub namespace: Option<String>,
269    pub description: Option<String>,
270    pub input_schema: Value,
271    pub output_schema: Option<Value>,
272    pub annotations: ToolAnnotations,
273    pub side_effect_level: SideEffectLevel,
274    pub capabilities: BTreeMap<String, Vec<String>>,
275    pub path_args: Vec<String>,
276    pub examples: Vec<Value>,
277    /// `harn`, `host_bridge`, `mcp_server`, `provider_native`, `deferred`,
278    /// or another forward-compatible source label.
279    pub source: String,
280    pub deferred: bool,
281    pub policy: BindingPolicyStatus,
282    pub metadata: Value,
283}
284
285impl Default for BindingManifestEntry {
286    fn default() -> Self {
287        Self {
288            name: String::new(),
289            binding: String::new(),
290            namespace: None,
291            description: None,
292            input_schema: serde_json::json!({"type": "object"}),
293            output_schema: None,
294            annotations: ToolAnnotations::default(),
295            side_effect_level: SideEffectLevel::None,
296            capabilities: BTreeMap::new(),
297            path_args: Vec::new(),
298            examples: Vec::new(),
299            source: "harn".to_string(),
300            deferred: false,
301            policy: BindingPolicyStatus::default(),
302            metadata: Value::Object(serde_json::Map::new()),
303        }
304    }
305}
306
307/// Stable prompt-visible manifest for a composition run.
308#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
309#[serde(default)]
310pub struct BindingManifest {
311    pub schema_version: u32,
312    pub bindings: Vec<BindingManifestEntry>,
313    pub side_effect_ceiling: SideEffectLevel,
314    pub metadata: Value,
315}
316
317impl Default for BindingManifest {
318    fn default() -> Self {
319        Self {
320            schema_version: BINDING_MANIFEST_SCHEMA_VERSION,
321            bindings: Vec::new(),
322            side_effect_ceiling: SideEffectLevel::ReadOnly,
323            metadata: Value::Object(serde_json::Map::new()),
324        }
325    }
326}
327
328impl BindingManifest {
329    pub fn new(mut bindings: Vec<BindingManifestEntry>, ceiling: SideEffectLevel) -> Self {
330        bindings.sort_by(|a, b| a.binding.cmp(&b.binding).then(a.name.cmp(&b.name)));
331        Self {
332            bindings,
333            side_effect_ceiling: ceiling,
334            ..Self::default()
335        }
336    }
337
338    pub fn to_value(&self) -> Value {
339        serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({"bindings": []}))
340    }
341
342    pub fn to_compact_value(&self) -> Value {
343        Value::Object(serde_json::Map::from_iter([
344            (
345                "schema_version".to_string(),
346                Value::Number(self.schema_version.into()),
347            ),
348            (
349                "side_effect_ceiling".to_string(),
350                serde_json::json!(self.side_effect_ceiling),
351            ),
352            (
353                "bindings".to_string(),
354                Value::Array(
355                    self.bindings
356                        .iter()
357                        .map(|binding| {
358                            serde_json::json!({
359                                "name": binding.name,
360                                "binding": binding.binding,
361                                "namespace": binding.namespace,
362                                "description": binding.description,
363                                "side_effect_level": binding.side_effect_level,
364                                "policy": binding.policy,
365                                "source": binding.source,
366                                "deferred": binding.deferred,
367                                "examples": binding.examples,
368                            })
369                        })
370                        .collect(),
371                ),
372            ),
373        ]))
374    }
375
376    pub fn hash(&self) -> Result<String, serde_json::Error> {
377        binding_manifest_hash(&self.to_value())
378    }
379
380    pub fn find_by_binding(&self, binding: &str) -> Option<&BindingManifestEntry> {
381        self.bindings.iter().find(|entry| entry.binding == binding)
382    }
383
384    pub fn find_by_name(&self, name: &str) -> Option<&BindingManifestEntry> {
385        self.bindings.iter().find(|entry| entry.name == name)
386    }
387}
388
389#[derive(Clone, Debug, Eq, PartialEq)]
390pub struct BindingManifestOptions {
391    pub side_effect_ceiling: SideEffectLevel,
392    pub include_denied: bool,
393    pub denied_tools: BTreeSet<String>,
394    pub gated_tools: BTreeSet<String>,
395}
396
397impl Default for BindingManifestOptions {
398    fn default() -> Self {
399        Self {
400            side_effect_ceiling: SideEffectLevel::ReadOnly,
401            include_denied: false,
402            denied_tools: BTreeSet::new(),
403            gated_tools: BTreeSet::new(),
404        }
405    }
406}
407
408/// Build a binding manifest from a Harn tool registry, MCP `tools/list`
409/// payload, or provider-native tool array.
410pub fn binding_manifest_from_tool_surface(
411    tools: &Value,
412    options: BindingManifestOptions,
413) -> BindingManifest {
414    let mut used_bindings = BTreeSet::new();
415    let annotations_by_name = crate::tool_surface::tool_annotations_from_spec(tools);
416    let mut entries = Vec::new();
417    for tool in tool_surface_entries(tools) {
418        let Some(name) = tool
419            .get("name")
420            .and_then(Value::as_str)
421            .filter(|s| !s.is_empty())
422        else {
423            continue;
424        };
425        let annotations = tool
426            .get("annotations")
427            .cloned()
428            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
429            .or_else(|| annotations_by_name.get(name).cloned())
430            .unwrap_or_default();
431        let side_effect_level = annotations.side_effect_level;
432        let mut policy = BindingPolicyStatus::default();
433        if options.denied_tools.contains(name) {
434            policy.disposition = BindingPolicyDisposition::Denied;
435            policy.reason = Some("denied by active tool policy".to_string());
436        } else if side_effect_level.rank() > options.side_effect_ceiling.rank() {
437            policy.disposition = BindingPolicyDisposition::Denied;
438            policy.reason = Some(format!(
439                "requires side-effect level '{}' above composition ceiling '{}'",
440                side_effect_level.as_str(),
441                options.side_effect_ceiling.as_str()
442            ));
443        } else if options.gated_tools.contains(name) {
444            policy.disposition = BindingPolicyDisposition::Gated;
445            policy.reason = Some("requires host approval before dispatch".to_string());
446        }
447        if !options.include_denied && policy.disposition == BindingPolicyDisposition::Denied {
448            continue;
449        }
450        let binding = unique_binding_identifier(name, &mut used_bindings);
451        let source = binding_source(&tool);
452        let deferred = tool
453            .get("defer_loading")
454            .and_then(Value::as_bool)
455            .or_else(|| {
456                tool.get("function")
457                    .and_then(|function| function.get("defer_loading"))
458                    .and_then(Value::as_bool)
459            })
460            .unwrap_or(source == "deferred");
461        let input_schema = tool
462            .get("inputSchema")
463            .or_else(|| tool.get("input_schema"))
464            .or_else(|| tool.get("parameters"))
465            .or_else(|| tool.get("function").and_then(|f| f.get("parameters")))
466            .cloned()
467            .unwrap_or_else(|| serde_json::json!({"type": "object"}));
468        let output_schema = tool
469            .get("outputSchema")
470            .or_else(|| tool.get("output_schema"))
471            .or_else(|| tool.get("returns"))
472            .or_else(|| {
473                tool.get("function")
474                    .and_then(|f| f.get("x-harn-output-schema"))
475            })
476            .cloned();
477        let examples = tool
478            .get("examples")
479            .and_then(Value::as_array)
480            .cloned()
481            .unwrap_or_default();
482        entries.push(BindingManifestEntry {
483            name: name.to_string(),
484            binding,
485            namespace: tool
486                .get("namespace")
487                .and_then(Value::as_str)
488                .map(ToOwned::to_owned),
489            description: tool
490                .get("description")
491                .or_else(|| tool.get("function").and_then(|f| f.get("description")))
492                .and_then(Value::as_str)
493                .filter(|s| !s.is_empty())
494                .map(ToOwned::to_owned),
495            input_schema,
496            output_schema,
497            side_effect_level,
498            capabilities: annotations.capabilities.clone(),
499            path_args: annotations.arg_schema.path_params.clone(),
500            annotations,
501            examples,
502            source,
503            deferred,
504            policy,
505            metadata: tool
506                .get("metadata")
507                .or_else(|| tool.get("_meta"))
508                .cloned()
509                .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
510        });
511    }
512    BindingManifest::new(entries, options.side_effect_ceiling)
513}
514
515fn tool_surface_entries(value: &Value) -> Vec<Value> {
516    match value {
517        Value::Array(items) => items.clone(),
518        Value::Object(map) => {
519            if let Some(Value::Array(items)) = map.get("tools") {
520                return items.clone();
521            }
522            if map.get("name").and_then(Value::as_str).is_some() {
523                return vec![value.clone()];
524            }
525            Vec::new()
526        }
527        _ => Vec::new(),
528    }
529}
530
531fn binding_source(tool: &Value) -> String {
532    if tool
533        .get("defer_loading")
534        .and_then(Value::as_bool)
535        .unwrap_or(false)
536    {
537        return "deferred".to_string();
538    }
539    if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
540        return executor.to_string();
541    }
542    if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
543        return "mcp_server".to_string();
544    }
545    if tool.get("function").is_some() {
546        return "provider_native".to_string();
547    }
548    "harn".to_string()
549}
550
551fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
552    let base = sanitize_binding_identifier(name);
553    if used.insert(base.clone()) {
554        return base;
555    }
556    for index in 2.. {
557        let candidate = format!("{base}_{index}");
558        if used.insert(candidate.clone()) {
559            return candidate;
560        }
561    }
562    unreachable!("unbounded identifier suffix search")
563}
564
565fn sanitize_binding_identifier(name: &str) -> String {
566    let mut out = String::new();
567    for (idx, ch) in name.chars().enumerate() {
568        if ch == '_' || ch.is_ascii_alphanumeric() {
569            if idx == 0 && ch.is_ascii_digit() {
570                out.push_str("tool_");
571            }
572            out.push(ch);
573        } else {
574            out.push('_');
575        }
576    }
577    while out.contains("__") {
578        out = out.replace("__", "_");
579    }
580    let out = out.trim_matches('_').to_string();
581    let out = if out.is_empty() {
582        "tool".to_string()
583    } else {
584        out
585    };
586    if HARN_KEYWORDS.contains(&out.as_str()) {
587        format!("tool_{out}")
588    } else {
589        out
590    }
591}
592
593const HARN_KEYWORDS: &[&str] = &[
594    "agent",
595    "as",
596    "await",
597    "break",
598    "catch",
599    "continue",
600    "defer",
601    "else",
602    "enum",
603    "false",
604    "fn",
605    "for",
606    "if",
607    "impl",
608    "import",
609    "in",
610    "interface",
611    "let",
612    "match",
613    "nil",
614    "pipeline",
615    "pub",
616    "return",
617    "skill",
618    "spawn",
619    "struct",
620    "throw",
621    "true",
622    "try",
623    "type",
624    "var",
625    "while",
626];
627
628#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
629#[serde(default)]
630pub struct CompositionExecutionLimits {
631    pub max_operations: u64,
632    pub timeout_ms: Option<u64>,
633    pub max_output_bytes: u64,
634}
635
636impl Default for CompositionExecutionLimits {
637    fn default() -> Self {
638        Self {
639            max_operations: 64,
640            timeout_ms: Some(10_000),
641            max_output_bytes: 64 * 1024,
642        }
643    }
644}
645
646#[derive(Clone, Debug, Serialize, Deserialize)]
647#[serde(default)]
648pub struct CompositionExecutionRequest {
649    pub session_id: Option<String>,
650    pub run_id: String,
651    pub language: String,
652    pub snippet: String,
653    pub manifest: BindingManifest,
654    pub requested_side_effect_ceiling: SideEffectLevel,
655    pub limits: CompositionExecutionLimits,
656    pub metadata: Value,
657}
658
659impl Default for CompositionExecutionRequest {
660    fn default() -> Self {
661        Self {
662            session_id: None,
663            run_id: String::new(),
664            language: "harn".to_string(),
665            snippet: String::new(),
666            manifest: BindingManifest::default(),
667            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
668            limits: CompositionExecutionLimits::default(),
669            metadata: Value::Object(serde_json::Map::new()),
670        }
671    }
672}
673
674#[derive(Clone, Debug, Serialize, Deserialize)]
675pub struct CompositionExecutionReport {
676    pub schema_version: u32,
677    pub ok: bool,
678    pub run: CompositionRunEnvelope,
679    pub child_calls: Vec<CompositionChildCall>,
680    pub child_results: Vec<CompositionChildResult>,
681    pub summary: String,
682}
683
684#[derive(Clone, Debug, Serialize, Deserialize)]
685pub struct CompositionToolOutput {
686    pub value: Option<Value>,
687    pub error: Option<String>,
688    pub error_category: Option<ToolCallErrorCategory>,
689    pub executor: Option<ToolExecutor>,
690}
691
692impl CompositionToolOutput {
693    pub fn ok(value: Value) -> Self {
694        Self {
695            value: Some(value),
696            error: None,
697            error_category: None,
698            executor: Some(ToolExecutor::HarnBuiltin),
699        }
700    }
701
702    pub fn error(message: impl Into<String>, category: ToolCallErrorCategory) -> Self {
703        Self {
704            value: None,
705            error: Some(message.into()),
706            error_category: Some(category),
707            executor: Some(ToolExecutor::HarnBuiltin),
708        }
709    }
710}
711
712#[async_trait::async_trait(?Send)]
713pub trait CompositionToolHost {
714    async fn call(&self, binding: &BindingManifestEntry, input: Value) -> CompositionToolOutput;
715}
716
717struct ExecutionState {
718    request: CompositionExecutionRequest,
719    calls: Vec<CompositionChildCall>,
720    results: Vec<CompositionChildResult>,
721    clock: Arc<dyn harn_clock::Clock>,
722    started_ms: i64,
723}
724
725impl ExecutionState {
726    fn next_call(
727        &mut self,
728        tool_name: &str,
729        input: Value,
730    ) -> Result<(BindingManifestEntry, CompositionChildCall), VmError> {
731        if self.results.len() as u64 >= self.request.limits.max_operations {
732            return Err(VmError::Runtime(format!(
733                "composition exceeded max_operations={}",
734                self.request.limits.max_operations
735            )));
736        }
737        if let Some(timeout_ms) = self.request.limits.timeout_ms {
738            if elapsed_ms(&*self.clock, self.started_ms) > timeout_ms {
739                return Err(VmError::Runtime(format!(
740                    "composition exceeded timeout_ms={timeout_ms}"
741                )));
742            }
743        }
744        let binding = self
745            .request
746            .manifest
747            .find_by_name(tool_name)
748            .or_else(|| self.request.manifest.find_by_binding(tool_name))
749            .cloned()
750            .ok_or_else(|| {
751                VmError::Runtime(format!("composition binding '{tool_name}' not found"))
752            })?;
753        let call = self.push_call(&binding, input);
754        if binding.policy.disposition == BindingPolicyDisposition::Denied {
755            let message = format!(
756                "composition binding '{}' denied{}",
757                binding.name,
758                binding
759                    .policy
760                    .reason
761                    .as_deref()
762                    .map(|reason| format!(": {reason}"))
763                    .unwrap_or_default()
764            );
765            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
766            return Err(VmError::Runtime(message));
767        }
768        if binding.policy.disposition == BindingPolicyDisposition::Gated {
769            let message = format!(
770                "composition binding '{}' requires approval and cannot run in read-only mode",
771                binding.name
772            );
773            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
774            return Err(VmError::Runtime(message));
775        }
776        if binding.side_effect_level.rank() > self.request.requested_side_effect_ceiling.rank() {
777            let message = format!(
778                "composition binding '{}' requires side-effect level '{}' above requested ceiling '{}'",
779                binding.name,
780                binding.side_effect_level.as_str(),
781                self.request.requested_side_effect_ceiling.as_str()
782            );
783            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
784            return Err(VmError::Runtime(message));
785        }
786        Ok((binding, call))
787    }
788
789    fn push_call(&mut self, binding: &BindingManifestEntry, input: Value) -> CompositionChildCall {
790        let operation_index = self.calls.len() as u64;
791        let call = CompositionChildCall {
792            run_id: self.request.run_id.clone(),
793            tool_call_id: format!("{}:{operation_index}", self.request.run_id),
794            tool_name: binding.name.clone(),
795            operation_index,
796            annotations: Some(binding.annotations.clone()),
797            requested_side_effect_level: binding.side_effect_level,
798            policy_context: serde_json::json!({
799                "disposition": binding.policy.disposition,
800                "reason": binding.policy.reason,
801                "ceiling": self.request.requested_side_effect_ceiling,
802            }),
803            raw_input: input,
804        };
805        self.calls.push(call.clone());
806        call
807    }
808
809    fn push_failed_result(
810        &mut self,
811        call: &CompositionChildCall,
812        message: &str,
813        category: ToolCallErrorCategory,
814    ) {
815        self.results.push(CompositionChildResult {
816            run_id: call.run_id.clone(),
817            tool_call_id: call.tool_call_id.clone(),
818            tool_name: call.tool_name.clone(),
819            operation_index: call.operation_index,
820            status: ToolCallStatus::Failed,
821            raw_output: None,
822            error: Some(message.to_string()),
823            error_category: Some(category),
824            executor: Some(ToolExecutor::HarnBuiltin),
825            duration_ms: Some(0),
826            execution_duration_ms: Some(0),
827        });
828    }
829
830    fn push_result(
831        &mut self,
832        call: &CompositionChildCall,
833        output: &CompositionToolOutput,
834        elapsed_ms: u64,
835    ) {
836        if self
837            .results
838            .iter()
839            .any(|result| result.tool_call_id == call.tool_call_id)
840        {
841            return;
842        }
843        self.results.push(CompositionChildResult {
844            run_id: call.run_id.clone(),
845            tool_call_id: call.tool_call_id.clone(),
846            tool_name: call.tool_name.clone(),
847            operation_index: call.operation_index,
848            status: if output.error.is_some() {
849                ToolCallStatus::Failed
850            } else {
851                ToolCallStatus::Completed
852            },
853            raw_output: output.value.clone(),
854            error: output.error.clone(),
855            error_category: output.error_category,
856            executor: output.executor.clone(),
857            duration_ms: Some(elapsed_ms),
858            execution_duration_ms: Some(elapsed_ms),
859        });
860    }
861}
862
863/// Execute a read-only Harn-native composition snippet against a manifest.
864pub async fn execute_harn_composition(
865    mut request: CompositionExecutionRequest,
866    host: Rc<dyn CompositionToolHost>,
867) -> CompositionExecutionReport {
868    if request.run_id.trim().is_empty() {
869        request.run_id = uuid::Uuid::now_v7().to_string();
870    }
871    if request.language.trim().is_empty() {
872        request.language = "harn".to_string();
873    }
874    let manifest_hash = request
875        .manifest
876        .hash()
877        .unwrap_or_else(|_| "sha256:manifest_hash_error".to_string());
878    let snippet_hash = composition_snippet_hash(&request.language, &request.snippet);
879    let mut run = CompositionRunEnvelope::read_only(
880        request.run_id.clone(),
881        request.language.clone(),
882        snippet_hash,
883        manifest_hash,
884    );
885    let session_id = request.session_id.clone();
886    run.requested_side_effect_ceiling = request.requested_side_effect_ceiling;
887    run.metadata = request.metadata.clone();
888    if !run.metadata.is_object() {
889        run.metadata = Value::Object(serde_json::Map::new());
890    }
891    if let Some(session_id) = &session_id {
892        run.metadata["session_id"] = Value::String(session_id.clone());
893    }
894    let clock = harn_clock::RealClock::arc();
895    let started_ms = clock.monotonic_ms();
896
897    let result = if request.language != "harn" {
898        Err((
899            CompositionFailureCategory::UnsupportedLanguage,
900            format!("unsupported composition language '{}'", request.language),
901            Vec::new(),
902            Vec::new(),
903        ))
904    } else if request.requested_side_effect_ceiling.rank() > SideEffectLevel::ReadOnly.rank() {
905        Err((
906            CompositionFailureCategory::PolicyDenied,
907            "read-only composition executor refuses side-effect ceilings above read_only"
908                .to_string(),
909            Vec::new(),
910            Vec::new(),
911        ))
912    } else {
913        execute_harn_composition_inner(request, host).await
914    };
915
916    let report = match result {
917        Ok((value, stdout, calls, results)) => {
918            run.result = Some(value);
919            run.stdout = (!stdout.is_empty()).then_some(stdout);
920            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
921            CompositionExecutionReport {
922                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
923                ok: true,
924                summary: format!(
925                    "composition completed with {} child operation(s)",
926                    results.len()
927                ),
928                run,
929                child_calls: calls,
930                child_results: results,
931            }
932        }
933        Err((category, error, calls, results)) => {
934            run.failure_category = Some(category);
935            run.error = Some(error.clone());
936            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
937            CompositionExecutionReport {
938                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
939                ok: false,
940                summary: error,
941                run,
942                child_calls: calls,
943                child_results: results,
944            }
945        }
946    };
947    if let Some(session_id) = session_id {
948        emit_composition_report_events(&session_id, &report);
949    }
950    report
951}
952
953pub fn composition_report_events(
954    session_id: impl Into<String>,
955    report: &CompositionExecutionReport,
956) -> Vec<AgentEvent> {
957    let session_id = session_id.into();
958    let mut start_run = report.run.clone();
959    start_run.stdout = None;
960    start_run.stderr = None;
961    start_run.artifacts = Vec::new();
962    start_run.result = None;
963    start_run.failure_category = None;
964    start_run.error = None;
965    start_run.duration_ms = None;
966
967    let mut events = vec![AgentEvent::CompositionStart {
968        session_id: session_id.clone(),
969        run: start_run,
970    }];
971    for call in &report.child_calls {
972        events.push(AgentEvent::CompositionChildCall {
973            session_id: session_id.clone(),
974            call: call.clone(),
975        });
976        for result in report
977            .child_results
978            .iter()
979            .filter(|result| result.tool_call_id == call.tool_call_id)
980        {
981            events.push(AgentEvent::CompositionChildResult {
982                session_id: session_id.clone(),
983                result: result.clone(),
984            });
985        }
986    }
987    if report.ok {
988        events.push(AgentEvent::CompositionFinish {
989            session_id,
990            run: report.run.clone(),
991        });
992    } else {
993        events.push(AgentEvent::CompositionError {
994            session_id,
995            run: report.run.clone(),
996        });
997    }
998    events
999}
1000
1001fn emit_composition_report_events(session_id: &str, report: &CompositionExecutionReport) {
1002    for event in composition_report_events(session_id, report) {
1003        crate::llm::emit_live_agent_event_sync(&event);
1004    }
1005}
1006
1007async fn execute_harn_composition_inner(
1008    request: CompositionExecutionRequest,
1009    host: Rc<dyn CompositionToolHost>,
1010) -> Result<
1011    (
1012        Value,
1013        String,
1014        Vec<CompositionChildCall>,
1015        Vec<CompositionChildResult>,
1016    ),
1017    (
1018        CompositionFailureCategory,
1019        String,
1020        Vec<CompositionChildCall>,
1021        Vec<CompositionChildResult>,
1022    ),
1023> {
1024    let validation_source = composition_validation_source(&request.snippet);
1025    let validation_program = harn_parser::parse_source(&validation_source).map_err(|error| {
1026        (
1027            CompositionFailureCategory::SchemaValidation,
1028            format!("composition parse error: {error}"),
1029            Vec::new(),
1030            Vec::new(),
1031        )
1032    })?;
1033    validate_composition_program(&validation_program, &request.manifest).map_err(|error| {
1034        (
1035            CompositionFailureCategory::PolicyDenied,
1036            error,
1037            Vec::new(),
1038            Vec::new(),
1039        )
1040    })?;
1041
1042    let source = composition_source(&request.manifest, &request.snippet);
1043    let program = harn_parser::parse_source(&source).map_err(|error| {
1044        (
1045            CompositionFailureCategory::SchemaValidation,
1046            format!("composition parse error: {error}"),
1047            Vec::new(),
1048            Vec::new(),
1049        )
1050    })?;
1051    let chunk = crate::Compiler::new()
1052        .compile_named(&program, "main")
1053        .map_err(|error| {
1054            (
1055                CompositionFailureCategory::SchemaValidation,
1056                format!("composition compile error: {error}"),
1057                Vec::new(),
1058                Vec::new(),
1059            )
1060        })?;
1061
1062    let execution_clock = harn_clock::RealClock::arc();
1063    let execution_started_ms = execution_clock.monotonic_ms();
1064    let state = Rc::new(RefCell::new(ExecutionState {
1065        request,
1066        calls: Vec::new(),
1067        results: Vec::new(),
1068        clock: execution_clock,
1069        started_ms: execution_started_ms,
1070    }));
1071    let mut vm = Vm::new();
1072    crate::register_core_stdlib(&mut vm);
1073    register_composition_call_builtin(&mut vm, state.clone(), host);
1074    if let Some(timeout_ms) = state.borrow().request.limits.timeout_ms {
1075        vm.push_deadline_after(std::time::Duration::from_millis(timeout_ms));
1076    }
1077    vm.set_source_info("composition://snippet.harn", &source);
1078    match vm.execute(&chunk).await {
1079        Ok(value) => {
1080            let json = crate::llm::vm_value_to_json(&value);
1081            let stdout = vm.output().to_string();
1082            let state = state.borrow();
1083            let result_size = serde_json::to_vec(&json)
1084                .map(|bytes| bytes.len())
1085                .unwrap_or(0);
1086            let output_size = result_size.saturating_add(stdout.len());
1087            if output_size as u64 > state.request.limits.max_output_bytes {
1088                return Err((
1089                    CompositionFailureCategory::ExecutionError,
1090                    format!(
1091                        "composition output exceeded max_output_bytes={}",
1092                        state.request.limits.max_output_bytes
1093                    ),
1094                    state.calls.clone(),
1095                    state.results.clone(),
1096                ));
1097            }
1098            Ok((json, stdout, state.calls.clone(), state.results.clone()))
1099        }
1100        Err(error) => {
1101            let state = state.borrow();
1102            let category = if error.to_string().contains("denied")
1103                || error.to_string().contains("side-effect")
1104                || error.to_string().contains("approval")
1105            {
1106                CompositionFailureCategory::PolicyDenied
1107            } else if error.to_string().contains("Deadline exceeded")
1108                || error.to_string().contains("max_operations")
1109                || error.to_string().contains("timeout_ms")
1110                || error.to_string().contains("max_output_bytes")
1111            {
1112                CompositionFailureCategory::Timeout
1113            } else if state
1114                .results
1115                .iter()
1116                .any(|result| result.status == ToolCallStatus::Failed)
1117            {
1118                CompositionFailureCategory::ChildToolError
1119            } else {
1120                CompositionFailureCategory::ExecutionError
1121            };
1122            Err((
1123                category,
1124                error.to_string(),
1125                state.calls.clone(),
1126                state.results.clone(),
1127            ))
1128        }
1129    }
1130}
1131
1132fn register_composition_call_builtin(
1133    vm: &mut Vm,
1134    state: Rc<RefCell<ExecutionState>>,
1135    host: Rc<dyn CompositionToolHost>,
1136) {
1137    vm.register_async_builtin("__composition_call", move |args| {
1138        let state = state.clone();
1139        let host = host.clone();
1140        async move {
1141            let tool_name = args
1142                .first()
1143                .map(VmValue::display)
1144                .ok_or_else(|| VmError::Runtime("__composition_call: missing tool name".into()))?;
1145            let input = args
1146                .get(1)
1147                .map(crate::llm::vm_value_to_json)
1148                .unwrap_or_else(|| serde_json::json!({}));
1149            let (binding, call, clock) = {
1150                let mut state = state.borrow_mut();
1151                let (binding, call) = state.next_call(&tool_name, input.clone())?;
1152                (binding, call, state.clock.clone())
1153            };
1154            let started_ms = clock.monotonic_ms();
1155            let output = host.call(&binding, input).await;
1156            {
1157                let mut state = state.borrow_mut();
1158                state.push_result(&call, &output, elapsed_ms(&*clock, started_ms));
1159            }
1160            if let Some(error) = output.error {
1161                return Err(VmError::Runtime(error));
1162            }
1163            Ok(crate::json_to_vm_value(
1164                &output.value.unwrap_or(Value::Null),
1165            ))
1166        }
1167    });
1168}
1169
1170fn elapsed_ms(clock: &dyn harn_clock::Clock, started_ms: i64) -> u64 {
1171    clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64
1172}
1173
1174fn composition_validation_source(snippet: &str) -> String {
1175    let mut source = String::from("pipeline main() {\n");
1176    source.push_str(snippet);
1177    if !snippet.ends_with('\n') {
1178        source.push('\n');
1179    }
1180    source.push_str("}\n");
1181    source
1182}
1183
1184fn composition_source(manifest: &BindingManifest, snippet: &str) -> String {
1185    let mut source = String::new();
1186    for binding in &manifest.bindings {
1187        source.push_str(&format!(
1188            "fn {}(args = {{}}) {{ return __composition_call(\"{}\", args) }}\n",
1189            binding.binding,
1190            escape_harn_string(&binding.name)
1191        ));
1192    }
1193    source.push_str("pipeline main() {\n");
1194    source.push_str(snippet);
1195    if !snippet.ends_with('\n') {
1196        source.push('\n');
1197    }
1198    source.push_str("}\n");
1199    source
1200}
1201
1202fn escape_harn_string(value: &str) -> String {
1203    value.replace('\\', "\\\\").replace('"', "\\\"")
1204}
1205
1206fn validate_composition_program(
1207    program: &[harn_parser::SNode],
1208    manifest: &BindingManifest,
1209) -> Result<(), String> {
1210    use harn_parser::visit::walk_program;
1211    use harn_parser::Node;
1212
1213    let bindings = manifest
1214        .bindings
1215        .iter()
1216        .map(|entry| entry.binding.clone())
1217        .collect::<BTreeSet<_>>();
1218    let mut local_functions = BTreeSet::from(["__composition_call".to_string()]);
1219    walk_program(program, &mut |node| {
1220        if let Node::FnDecl { name, .. } = &node.node {
1221            local_functions.insert(name.clone());
1222        }
1223    });
1224
1225    let mut error = None;
1226    walk_program(program, &mut |node| {
1227        if error.is_some() {
1228            return;
1229        }
1230        match &node.node {
1231            Node::ImportDecl { .. } | Node::SelectiveImport { .. } => {
1232                error = Some("composition snippets cannot import modules".to_string());
1233            }
1234            Node::SpawnExpr { .. } | Node::Parallel { .. } => {
1235                error = Some("composition snippets cannot spawn or parallelize work".to_string());
1236            }
1237            Node::HitlExpr { .. } => {
1238                error = Some("composition snippets cannot request HITL directly".to_string());
1239            }
1240            Node::CostRoute { .. } => {
1241                error = Some("composition snippets cannot open LLM routing blocks".to_string());
1242            }
1243            Node::FunctionCall { name, .. } => {
1244                if DENIED_COMPOSITION_CALLS.contains(&name.as_str()) && !bindings.contains(name) {
1245                    error = Some(format!("composition snippets cannot call `{name}`"));
1246                } else if !bindings.contains(name)
1247                    && !local_functions.contains(name)
1248                    && !PURE_COMPOSITION_CALLS.contains(&name.as_str())
1249                {
1250                    error = Some(format!(
1251                        "composition call target `{name}` is not a manifest binding or pure helper"
1252                    ));
1253                }
1254            }
1255            _ => {}
1256        }
1257    });
1258    error.map_or(Ok(()), Err)
1259}
1260
1261const DENIED_COMPOSITION_CALLS: &[&str] = &[
1262    "append_file",
1263    "ask_user",
1264    "connector_call",
1265    "copy_file",
1266    "delete_file",
1267    "dual_control",
1268    "escalate_to",
1269    "event_log_emit",
1270    "event_log.emit",
1271    "exec",
1272    "host_call",
1273    "host_tool_call",
1274    "http_delete",
1275    "http_download",
1276    "http_get",
1277    "http_patch",
1278    "http_post",
1279    "http_put",
1280    "http_request",
1281    "llm_call",
1282    "mcp_call",
1283    "mcp_connect",
1284    "pg_execute",
1285    "pg_query",
1286    "request_approval",
1287    "secret_get",
1288    "write_file",
1289];
1290
1291const PURE_COMPOSITION_CALLS: &[&str] = &[
1292    "Ok",
1293    "Err",
1294    "abs",
1295    "assert",
1296    "assert_eq",
1297    "assert_ne",
1298    "base64_decode",
1299    "base64_encode",
1300    "ceil",
1301    "contains",
1302    "dedup_by",
1303    "dirname",
1304    "entries",
1305    "ends_with",
1306    "flat_map",
1307    "floor",
1308    "format",
1309    "group_by",
1310    "hash_value",
1311    "hex_decode",
1312    "hex_encode",
1313    "is_err",
1314    "is_ok",
1315    "join",
1316    "jq",
1317    "jq_first",
1318    "json_extract",
1319    "json_parse",
1320    "json_pointer",
1321    "json_stringify",
1322    "keys",
1323    "len",
1324    "lower",
1325    "parse_float_or",
1326    "parse_int_or",
1327    "split",
1328    "starts_with",
1329    "to_float",
1330    "to_int",
1331    "to_string",
1332    "trim",
1333    "upper",
1334    "values",
1335];
1336
1337pub struct StaticCompositionToolHost {
1338    outputs: BTreeMap<String, Value>,
1339}
1340
1341impl StaticCompositionToolHost {
1342    pub fn new(outputs: BTreeMap<String, Value>) -> Self {
1343        Self { outputs }
1344    }
1345}
1346
1347#[async_trait::async_trait(?Send)]
1348impl CompositionToolHost for StaticCompositionToolHost {
1349    async fn call(&self, binding: &BindingManifestEntry, input: Value) -> CompositionToolOutput {
1350        if let Some(value) = self.outputs.get(&binding.name) {
1351            return CompositionToolOutput::ok(value.clone());
1352        }
1353        if let Some(value) = binding.metadata.get("mock_output") {
1354            return CompositionToolOutput::ok(value.clone());
1355        }
1356        CompositionToolOutput::ok(serde_json::json!({
1357            "tool": binding.name,
1358            "input": input,
1359        }))
1360    }
1361}
1362
1363/// Dispatches every binding call to a caller-supplied Harn closure.
1364///
1365/// The closure is compiled in the calling VM and receives
1366/// `(binding_name: string, input: dict)`. Returning a value yields a successful
1367/// child result; raising an error fails the child call. Each invocation runs
1368/// on a fresh clone of the outer VM so closure-side builtins (`host_call`,
1369/// pipeline imports, etc.) resolve normally — the inner composition VM only
1370/// sees the manifest bindings plus pure helpers.
1371pub struct ClosureCompositionToolHost {
1372    closure: crate::VmClosure,
1373    outer_vm: Vm,
1374}
1375
1376impl ClosureCompositionToolHost {
1377    pub fn new(closure: crate::VmClosure, outer_vm: Vm) -> Self {
1378        Self { closure, outer_vm }
1379    }
1380}
1381
1382#[async_trait::async_trait(?Send)]
1383impl CompositionToolHost for ClosureCompositionToolHost {
1384    async fn call(&self, binding: &BindingManifestEntry, input: Value) -> CompositionToolOutput {
1385        let mut vm = self.outer_vm.child_vm();
1386        let args = vec![
1387            VmValue::String(Rc::from(binding.name.as_str())),
1388            crate::json_to_vm_value(&input),
1389        ];
1390        match vm.call_closure_pub(&self.closure, &args).await {
1391            Ok(value) => {
1392                let json = crate::llm::vm_value_to_json(&value);
1393                CompositionToolOutput::ok(json)
1394            }
1395            Err(error) => {
1396                CompositionToolOutput::error(error.to_string(), ToolCallErrorCategory::ToolError)
1397            }
1398        }
1399    }
1400}
1401
1402pub fn composition_search_examples(query: &str, limit: usize) -> Value {
1403    let mut examples = vec![
1404        serde_json::json!({
1405            "id": "read-summarize",
1406            "title": "Read two files and return a compact summary",
1407            "language": "harn",
1408            "snippet": "let readme = read_file({path: \"README.md\"})\nlet spec = read_file({path: \"spec/HARN_SPEC.md\", limit: 80})\nreturn {readme: readme, spec_excerpt: spec}",
1409            "required_side_effect_level": "read_only",
1410            "tools": ["read_file"]
1411        }),
1412        serde_json::json!({
1413            "id": "search-then-read",
1414            "title": "Search first, then read the best candidate",
1415            "language": "harn",
1416            "snippet": "let hits = search({query: \"CompositionRunEnvelope\"})\nreturn hits",
1417            "required_side_effect_level": "read_only",
1418            "tools": ["search"]
1419        }),
1420    ];
1421    if !query.trim().is_empty() {
1422        let q = query.to_ascii_lowercase();
1423        examples.retain(|example| {
1424            example
1425                .to_string()
1426                .to_ascii_lowercase()
1427                .contains(q.as_str())
1428        });
1429    }
1430    examples.truncate(limit.max(1));
1431    Value::Array(examples)
1432}
1433
1434pub fn composition_typescript_declarations(manifest: &BindingManifest) -> String {
1435    let mut out = String::from(
1436        "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };\n",
1437    );
1438    out.push_str("export type CompositionToolResult = JsonValue;\n\n");
1439    for binding in &manifest.bindings {
1440        if binding.policy.disposition != BindingPolicyDisposition::Allowed {
1441            continue;
1442        }
1443        let args_type = json_schema_to_typescript(&binding.input_schema);
1444        let result_type = binding
1445            .output_schema
1446            .as_ref()
1447            .map(json_schema_to_typescript)
1448            .unwrap_or_else(|| "CompositionToolResult".to_string());
1449        out.push_str(&format!(
1450            "export declare function {}(args: {}): Promise<{}>;\n",
1451            binding.binding, args_type, result_type
1452        ));
1453    }
1454    out
1455}
1456
1457fn json_schema_to_typescript(schema: &Value) -> String {
1458    if let Some(shorthand) = schema.as_str() {
1459        return match shorthand {
1460            "string" => "string".to_string(),
1461            "int" | "integer" | "float" | "number" => "number".to_string(),
1462            "bool" | "boolean" => "boolean".to_string(),
1463            "list" | "array" => "JsonValue[]".to_string(),
1464            "dict" | "object" => "{ [key: string]: JsonValue }".to_string(),
1465            _ => "JsonValue".to_string(),
1466        };
1467    }
1468    let schema_type = schema.get("type").and_then(Value::as_str);
1469    match schema_type {
1470        Some("string") => enum_string_literals(schema).unwrap_or_else(|| "string".to_string()),
1471        Some("integer") | Some("number") => "number".to_string(),
1472        Some("boolean") => "boolean".to_string(),
1473        Some("array") => {
1474            let item_type = schema
1475                .get("items")
1476                .map(json_schema_to_typescript)
1477                .unwrap_or_else(|| "JsonValue".to_string());
1478            format!("{item_type}[]")
1479        }
1480        Some("object") | None if schema.get("properties").is_some() => {
1481            let required = schema
1482                .get("required")
1483                .and_then(Value::as_array)
1484                .map(|items| {
1485                    items
1486                        .iter()
1487                        .filter_map(Value::as_str)
1488                        .collect::<BTreeSet<_>>()
1489                })
1490                .unwrap_or_default();
1491            let mut fields = Vec::new();
1492            if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
1493                for (name, value) in properties {
1494                    let marker = if required.contains(name.as_str()) {
1495                        ""
1496                    } else {
1497                        "?"
1498                    };
1499                    fields.push(format!(
1500                        "{}{}: {}",
1501                        typescript_property_name(name),
1502                        marker,
1503                        json_schema_to_typescript(value)
1504                    ));
1505                }
1506            }
1507            if fields.is_empty() {
1508                "{ [key: string]: JsonValue }".to_string()
1509            } else {
1510                format!("{{ {} }}", fields.join("; "))
1511            }
1512        }
1513        None if schema.as_object().is_some() => {
1514            let fields = schema
1515                .as_object()
1516                .into_iter()
1517                .flat_map(|properties| properties.iter())
1518                .map(|(name, value)| {
1519                    let marker = if value
1520                        .get("required")
1521                        .and_then(Value::as_bool)
1522                        .unwrap_or(true)
1523                    {
1524                        ""
1525                    } else {
1526                        "?"
1527                    };
1528                    format!(
1529                        "{}{}: {}",
1530                        typescript_property_name(name),
1531                        marker,
1532                        json_schema_to_typescript(value)
1533                    )
1534                })
1535                .collect::<Vec<_>>();
1536            if fields.is_empty() {
1537                "{ [key: string]: JsonValue }".to_string()
1538            } else {
1539                format!("{{ {} }}", fields.join("; "))
1540            }
1541        }
1542        Some("object") => "{ [key: string]: JsonValue }".to_string(),
1543        _ => "JsonValue".to_string(),
1544    }
1545}
1546
1547fn enum_string_literals(schema: &Value) -> Option<String> {
1548    let variants = schema.get("enum")?.as_array()?;
1549    let strings = variants
1550        .iter()
1551        .map(|value| value.as_str().map(|text| format!("{text:?}")))
1552        .collect::<Option<Vec<_>>>()?;
1553    (!strings.is_empty()).then(|| strings.join(" | "))
1554}
1555
1556fn typescript_property_name(name: &str) -> String {
1557    if name.chars().enumerate().all(|(idx, ch)| {
1558        ch == '_' || ch.is_ascii_alphanumeric() && (idx > 0 || !ch.is_ascii_digit())
1559    }) {
1560        name.to_string()
1561    } else {
1562        format!("{name:?}")
1563    }
1564}
1565
1566pub fn composition_crystallization_trace(
1567    report: &CompositionExecutionReport,
1568    options: &Value,
1569) -> Value {
1570    let trace_id = options
1571        .get("id")
1572        .and_then(Value::as_str)
1573        .map(ToOwned::to_owned)
1574        .unwrap_or_else(|| format!("composition_{}", report.run.run_id));
1575    let mut capabilities = BTreeSet::new();
1576    for call in &report.child_calls {
1577        if let Some(annotations) = &call.annotations {
1578            for (domain, ops) in &annotations.capabilities {
1579                for op in ops {
1580                    capabilities.insert(format!("{domain}.{op}"));
1581                }
1582            }
1583        }
1584    }
1585    let parent_parameters = serde_json::json!({
1586        "language": report.run.language,
1587        "snippet_hash": report.run.snippet_hash,
1588        "binding_manifest_hash": report.run.binding_manifest_hash,
1589        "requested_side_effect_ceiling": report.run.requested_side_effect_ceiling,
1590    });
1591    let mut actions = vec![serde_json::json!({
1592        "id": "composition_parent",
1593        "kind": "composition_run",
1594        "name": "execute_composition",
1595        "inputs": parent_parameters,
1596        "parameters": parent_parameters,
1597        "output": report.run.result,
1598        "observed_output": report.run.result,
1599        "capabilities": capabilities.into_iter().collect::<Vec<_>>(),
1600        "side_effects": [],
1601        "duration_ms": report.run.duration_ms.unwrap_or(0),
1602        "deterministic": true,
1603        "fuzzy": false,
1604        "metadata": {
1605            "source_kind": "composition_parent_run",
1606            "composition_run_id": report.run.run_id,
1607            "composition_schema_version": report.schema_version,
1608            "child_count": report.child_calls.len(),
1609            "ok": report.ok,
1610            "failure_category": report.run.failure_category,
1611        }
1612    })];
1613    actions.extend(
1614        report
1615            .child_calls
1616            .iter()
1617            .map(|call| {
1618                let result = report
1619                    .child_results
1620                    .iter()
1621                    .find(|result| result.tool_call_id == call.tool_call_id);
1622                let capabilities = call
1623                    .annotations
1624                    .as_ref()
1625                    .map(|annotations| {
1626                        annotations
1627                            .capabilities
1628                            .iter()
1629                            .flat_map(|(domain, ops)| {
1630                                ops.iter().map(move |op| format!("{domain}.{op}"))
1631                            })
1632                            .collect::<Vec<_>>()
1633                    })
1634                    .unwrap_or_default();
1635                serde_json::json!({
1636                    "id": format!("composition_child_{}", call.operation_index),
1637                    "kind": "tool_call",
1638                    "name": call.tool_name,
1639                    "inputs": call.raw_input,
1640                    "parameters": call.raw_input,
1641                    "output": result.and_then(|result| result.raw_output.clone()),
1642                    "observed_output": result.and_then(|result| result.raw_output.clone()),
1643                    "capabilities": capabilities,
1644                    "side_effects": [],
1645                    "duration_ms": result.and_then(|result| result.duration_ms).unwrap_or(0),
1646                    "deterministic": true,
1647                    "fuzzy": false,
1648                    "metadata": {
1649                        "source_kind": "composition_child_call",
1650                        "composition_run_id": report.run.run_id,
1651                        "composition_tool_call_id": call.tool_call_id,
1652                        "requested_side_effect_level": call.requested_side_effect_level,
1653                        "annotations": call.annotations,
1654                        "policy_context": call.policy_context,
1655                        "status": result.map(|result| result.status),
1656                        "error_category": result.and_then(|result| result.error_category),
1657                    }
1658                })
1659            })
1660            .collect::<Vec<_>>(),
1661    );
1662    let replay_run = composition_replay_run(report, &trace_id);
1663    serde_json::json!({
1664        "version": 1,
1665        "id": trace_id,
1666        "source": "composition_run",
1667        "source_hash": report.run.snippet_hash,
1668        "workflow_id": options.get("workflow_id").and_then(Value::as_str).unwrap_or("composition_candidate"),
1669        "flow": {
1670            "trace_id": report.run.run_id,
1671            "agent_run_id": options.get("agent_run_id").and_then(Value::as_str),
1672            "transcript_ref": options.get("transcript_ref").and_then(Value::as_str),
1673        },
1674        "actions": actions,
1675        "replay_run": replay_run,
1676        "replay_allowlist": [
1677            {
1678                "path": "/run_id",
1679                "reason": "run ids are allocated per execution"
1680            },
1681            {
1682                "path": "/effect_receipts/*/run_id",
1683                "reason": "composition receipts retain source run lineage"
1684            },
1685            {
1686                "path": "/effect_receipts/*/tool_call_id",
1687                "reason": "composition child call ids include the source run id"
1688            },
1689            {
1690                "path": "/policy_decisions/*/run_id",
1691                "reason": "composition policy decisions retain source run lineage"
1692            },
1693            {
1694                "path": "/policy_decisions/*/tool_call_id",
1695                "reason": "composition policy decision ids include the source run id"
1696            }
1697        ],
1698        "metadata": {
1699            "source_kind": "composition_run",
1700            "composition_schema_version": report.schema_version,
1701            "run_id": report.run.run_id,
1702            "snippet_hash": report.run.snippet_hash,
1703            "binding_manifest_hash": report.run.binding_manifest_hash,
1704            "requested_side_effect_ceiling": report.run.requested_side_effect_ceiling,
1705            "ok": report.ok,
1706            "failure_category": report.run.failure_category,
1707            "child_count": report.child_calls.len(),
1708        },
1709    })
1710}
1711
1712fn composition_replay_run(report: &CompositionExecutionReport, trace_id: &str) -> Value {
1713    let event_log_entries = composition_report_events(trace_id, report)
1714        .into_iter()
1715        .filter_map(|event| serde_json::to_value(event).ok())
1716        .collect::<Vec<_>>();
1717    let mut effect_receipts = vec![serde_json::json!({
1718        "kind": "composition_parent",
1719        "run_id": report.run.run_id,
1720        "schema_version": report.schema_version,
1721        "snippet_hash": report.run.snippet_hash,
1722        "binding_manifest_hash": report.run.binding_manifest_hash,
1723        "requested_side_effect_ceiling": report.run.requested_side_effect_ceiling,
1724        "ok": report.ok,
1725        "failure_category": report.run.failure_category,
1726        "result": report.run.result,
1727        "stdout": report.run.stdout,
1728    })];
1729    let mut policy_decisions = Vec::new();
1730    for call in &report.child_calls {
1731        let result = report
1732            .child_results
1733            .iter()
1734            .find(|result| result.tool_call_id == call.tool_call_id);
1735        effect_receipts.push(serde_json::json!({
1736            "kind": "composition_child",
1737            "run_id": report.run.run_id,
1738            "tool_call_id": call.tool_call_id,
1739            "tool_name": call.tool_name,
1740            "operation_index": call.operation_index,
1741            "requested_side_effect_level": call.requested_side_effect_level,
1742            "input": call.raw_input,
1743            "status": result.map(|result| result.status),
1744            "error_category": result.and_then(|result| result.error_category),
1745            "output": result.and_then(|result| result.raw_output.clone()),
1746        }));
1747        policy_decisions.push(serde_json::json!({
1748            "kind": "composition_child_policy",
1749            "run_id": report.run.run_id,
1750            "tool_call_id": call.tool_call_id,
1751            "tool_name": call.tool_name,
1752            "requested_side_effect_level": call.requested_side_effect_level,
1753            "policy_context": call.policy_context,
1754        }));
1755    }
1756    serde_json::json!({
1757        "run_id": report.run.run_id,
1758        "event_log_entries": event_log_entries,
1759        "effect_receipts": effect_receipts,
1760        "policy_decisions": policy_decisions,
1761    })
1762}
1763
1764pub fn register_composition_builtins(vm: &mut Vm) {
1765    vm.register_builtin("composition_binding_manifest", |args, _out| {
1766        let tools = args
1767            .first()
1768            .map(crate::llm::vm_value_to_json)
1769            .unwrap_or(Value::Null);
1770        let options_json = args
1771            .get(1)
1772            .map(crate::llm::vm_value_to_json)
1773            .unwrap_or(Value::Null);
1774        let mut options = BindingManifestOptions::default();
1775        if let Some(ceiling) = options_json
1776            .get("side_effect_ceiling")
1777            .and_then(Value::as_str)
1778        {
1779            options.side_effect_ceiling = SideEffectLevel::parse(ceiling);
1780        }
1781        if let Some(include_denied) = options_json.get("include_denied").and_then(Value::as_bool) {
1782            options.include_denied = include_denied;
1783        }
1784        options.denied_tools = string_set_option(&options_json, "denied_tools");
1785        options.gated_tools = string_set_option(&options_json, "gated_tools");
1786        let manifest = binding_manifest_from_tool_surface(&tools, options);
1787        let value = if options_json.get("form").and_then(Value::as_str) == Some("compact") {
1788            manifest.to_compact_value()
1789        } else {
1790            manifest.to_value()
1791        };
1792        Ok(crate::json_to_vm_value(&value))
1793    });
1794
1795    vm.register_builtin("composition_search_examples", |args, _out| {
1796        let query = args.first().map(VmValue::display).unwrap_or_default();
1797        let limit = args
1798            .get(1)
1799            .and_then(|value| match value {
1800                VmValue::Int(n) => Some((*n).max(1) as usize),
1801                _ => None,
1802            })
1803            .unwrap_or(10);
1804        Ok(crate::json_to_vm_value(&composition_search_examples(
1805            &query, limit,
1806        )))
1807    });
1808
1809    vm.register_builtin("composition_typescript_declarations", |args, _out| {
1810        let manifest_value = args
1811            .first()
1812            .map(crate::llm::vm_value_to_json)
1813            .ok_or_else(|| {
1814                VmError::Runtime("composition_typescript_declarations: manifest is required".into())
1815            })?;
1816        let manifest: BindingManifest =
1817            serde_json::from_value(manifest_value).map_err(|error| {
1818                VmError::Runtime(format!(
1819                    "composition_typescript_declarations: invalid manifest: {error}"
1820                ))
1821            })?;
1822        Ok(VmValue::String(Rc::from(
1823            composition_typescript_declarations(&manifest),
1824        )))
1825    });
1826
1827    vm.register_builtin("composition_crystallization_trace", |args, _out| {
1828        let report_value = args
1829            .first()
1830            .map(crate::llm::vm_value_to_json)
1831            .ok_or_else(|| {
1832                VmError::Runtime("composition_crystallization_trace: report is required".into())
1833            })?;
1834        let report: CompositionExecutionReport =
1835            serde_json::from_value(report_value).map_err(|error| {
1836                VmError::Runtime(format!(
1837                    "composition_crystallization_trace: invalid report: {error}"
1838                ))
1839            })?;
1840        let options = args
1841            .get(1)
1842            .map(crate::llm::vm_value_to_json)
1843            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1844        Ok(crate::json_to_vm_value(&composition_crystallization_trace(
1845            &report, &options,
1846        )))
1847    });
1848
1849    vm.register_async_builtin("composition_execute", |args| async move {
1850        let snippet = args
1851            .first()
1852            .map(VmValue::display)
1853            .ok_or_else(|| VmError::Runtime("composition_execute: snippet is required".into()))?;
1854        let manifest_value = args
1855            .get(1)
1856            .map(crate::llm::vm_value_to_json)
1857            .ok_or_else(|| VmError::Runtime("composition_execute: manifest is required".into()))?;
1858        let dispatcher = args.get(2).and_then(|value| match value {
1859            VmValue::Closure(closure) => Some((**closure).clone()),
1860            VmValue::Dict(dict) => match dict.get("dispatcher") {
1861                Some(VmValue::Closure(closure)) => Some((**closure).clone()),
1862                _ => None,
1863            },
1864            _ => None,
1865        });
1866        let mut request = CompositionExecutionRequest {
1867            snippet,
1868            manifest: serde_json::from_value(manifest_value).map_err(|error| {
1869                VmError::Runtime(format!("composition_execute: invalid manifest: {error}"))
1870            })?,
1871            ..CompositionExecutionRequest::default()
1872        };
1873        if let Some(options) = args.get(2).map(crate::llm::vm_value_to_json) {
1874            if let Some(session_id) = options.get("session_id").and_then(Value::as_str) {
1875                request.session_id = Some(session_id.to_string());
1876            }
1877            if let Some(run_id) = options.get("run_id").and_then(Value::as_str) {
1878                request.run_id = run_id.to_string();
1879            }
1880            if let Some(max_operations) = options.get("max_operations").and_then(Value::as_u64) {
1881                request.limits.max_operations = max_operations;
1882            }
1883            if let Some(timeout_ms) = options.get("timeout_ms").and_then(Value::as_u64) {
1884                request.limits.timeout_ms = Some(timeout_ms);
1885            }
1886            if let Some(max_output_bytes) = options.get("max_output_bytes").and_then(Value::as_u64)
1887            {
1888                request.limits.max_output_bytes = max_output_bytes;
1889            }
1890        }
1891        let host: Rc<dyn CompositionToolHost> = match dispatcher {
1892            Some(closure) => {
1893                let outer_vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
1894                    VmError::Runtime(
1895                        "composition_execute: dispatcher requires an async builtin VM context"
1896                            .into(),
1897                    )
1898                })?;
1899                Rc::new(ClosureCompositionToolHost::new(closure, outer_vm))
1900            }
1901            None => Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
1902        };
1903        let report = execute_harn_composition(request, host).await;
1904        Ok(crate::json_to_vm_value(
1905            &serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"ok": false})),
1906        ))
1907    });
1908}
1909
1910fn string_set_option(value: &Value, key: &str) -> BTreeSet<String> {
1911    value
1912        .get(key)
1913        .and_then(Value::as_array)
1914        .map(|items| {
1915            items
1916                .iter()
1917                .filter_map(Value::as_str)
1918                .map(ToOwned::to_owned)
1919                .collect()
1920        })
1921        .unwrap_or_default()
1922}
1923
1924#[cfg(test)]
1925mod tests {
1926    use super::*;
1927
1928    #[test]
1929    fn snippet_hash_includes_language() {
1930        let harn = composition_snippet_hash("harn", "read_file(\"AGENTS.md\")");
1931        let ts = composition_snippet_hash("typescript", "read_file(\"AGENTS.md\")");
1932        assert_ne!(harn, ts);
1933        assert!(harn.starts_with("sha256:"));
1934    }
1935
1936    #[test]
1937    fn binding_manifest_hash_is_stable_for_identical_values() {
1938        let manifest = serde_json::json!({
1939            "bindings": [
1940                {
1941                    "name": "read_file",
1942                    "annotations": {"side_effect_level": "read_only"}
1943                }
1944            ]
1945        });
1946        assert_eq!(
1947            binding_manifest_hash(&manifest).unwrap(),
1948            binding_manifest_hash(&manifest).unwrap()
1949        );
1950    }
1951
1952    #[test]
1953    fn child_call_preserves_mutation_annotations() {
1954        let call = CompositionChildCall {
1955            run_id: "run-1".into(),
1956            tool_call_id: "tool-1".into(),
1957            tool_name: "write_file".into(),
1958            operation_index: 0,
1959            requested_side_effect_level: SideEffectLevel::WorkspaceWrite,
1960            annotations: Some(ToolAnnotations {
1961                side_effect_level: SideEffectLevel::WorkspaceWrite,
1962                ..ToolAnnotations::default()
1963            }),
1964            raw_input: serde_json::json!({"path": "src/lib.rs"}),
1965            ..CompositionChildCall::default()
1966        };
1967        let encoded = serde_json::to_value(&call).unwrap();
1968        assert_eq!(encoded["requested_side_effect_level"], "workspace_write");
1969        assert_eq!(
1970            encoded["annotations"]["side_effect_level"],
1971            "workspace_write"
1972        );
1973    }
1974
1975    #[test]
1976    fn binding_manifest_projects_policy_and_stable_binding_names() {
1977        let tools = serde_json::json!({
1978            "_type": "tool_registry",
1979            "tools": [
1980                {
1981                    "name": "read.file",
1982                    "description": "Read a file",
1983                    "parameters": {"type": "object", "required": ["path"]},
1984                    "annotations": {
1985                        "kind": "read",
1986                        "side_effect_level": "read_only",
1987                        "arg_schema": {"path_params": ["path"]},
1988                        "capabilities": {"workspace": ["read_text"]},
1989                        "inline_result": true
1990                    }
1991                },
1992                {
1993                    "name": "write_file",
1994                    "parameters": {"type": "object"},
1995                    "annotations": {
1996                        "kind": "edit",
1997                        "side_effect_level": "workspace_write"
1998                    }
1999                },
2000                {
2001                    "name": "host.read",
2002                    "executor": "host_bridge",
2003                    "parameters": {"type": "object"},
2004                    "annotations": {
2005                        "kind": "read",
2006                        "side_effect_level": "read_only"
2007                    }
2008                },
2009                {
2010                    "name": "mcp.search",
2011                    "_mcp_server": "docs",
2012                    "parameters": {"type": "object"},
2013                    "annotations": {
2014                        "kind": "search",
2015                        "side_effect_level": "read_only"
2016                    }
2017                },
2018                {
2019                    "name": "rare.lookup",
2020                    "defer_loading": true,
2021                    "parameters": {"type": "object"},
2022                    "annotations": {
2023                        "kind": "search",
2024                        "side_effect_level": "read_only"
2025                    }
2026                }
2027            ]
2028        });
2029        let manifest = binding_manifest_from_tool_surface(
2030            &tools,
2031            BindingManifestOptions {
2032                side_effect_ceiling: SideEffectLevel::ReadOnly,
2033                ..BindingManifestOptions::default()
2034            },
2035        );
2036        let read = manifest.find_by_name("read.file").expect("read binding");
2037        assert_eq!(read.binding, "read_file");
2038        assert_eq!(read.path_args, vec!["path"]);
2039        assert_eq!(read.policy.disposition, BindingPolicyDisposition::Allowed);
2040        assert!(manifest.find_by_name("write_file").is_none());
2041        assert_eq!(
2042            manifest
2043                .find_by_name("host.read")
2044                .expect("host binding")
2045                .source,
2046            "host_bridge"
2047        );
2048        assert_eq!(
2049            manifest
2050                .find_by_name("mcp.search")
2051                .expect("mcp binding")
2052                .source,
2053            "mcp_server"
2054        );
2055        let deferred = manifest
2056            .find_by_name("rare.lookup")
2057            .expect("deferred binding");
2058        assert!(deferred.deferred);
2059        assert_eq!(deferred.source, "deferred");
2060        let manifest_with_denied = binding_manifest_from_tool_surface(
2061            &tools,
2062            BindingManifestOptions {
2063                side_effect_ceiling: SideEffectLevel::ReadOnly,
2064                include_denied: true,
2065                ..BindingManifestOptions::default()
2066            },
2067        );
2068        let write = manifest_with_denied
2069            .find_by_name("write_file")
2070            .expect("write binding");
2071        assert_eq!(write.policy.disposition, BindingPolicyDisposition::Denied);
2072        assert!(manifest.hash().unwrap().starts_with("sha256:"));
2073    }
2074
2075    #[test]
2076    fn manifest_compact_form_and_typescript_declarations_are_stable() {
2077        let tools = serde_json::json!([
2078            {
2079                "name": "read.file",
2080                "parameters": {
2081                    "type": "object",
2082                    "required": ["path"],
2083                    "properties": {
2084                        "path": {"type": "string"},
2085                        "limit": {"type": "integer"}
2086                    }
2087                },
2088                "returns": {
2089                    "type": "object",
2090                    "properties": {"text": {"type": "string"}}
2091                },
2092                "annotations": {"kind": "read", "side_effect_level": "read_only"}
2093            }
2094        ]);
2095        let manifest =
2096            binding_manifest_from_tool_surface(&tools, BindingManifestOptions::default());
2097        let compact = manifest.to_compact_value();
2098        assert_eq!(compact["bindings"][0]["binding"], "read_file");
2099        assert!(compact["bindings"][0].get("input_schema").is_none());
2100        let declarations = composition_typescript_declarations(&manifest);
2101        assert!(declarations.contains("export declare function read_file"));
2102        assert!(declarations.contains("path: string"));
2103        assert!(declarations.contains("limit?: number"));
2104    }
2105
2106    #[tokio::test(flavor = "current_thread")]
2107    async fn harn_composition_executes_read_only_binding_and_records_child_trace() {
2108        let tools = serde_json::json!([
2109            {
2110                "name": "read_file",
2111                "description": "Read a file",
2112                "parameters": {"type": "object", "required": ["path"]},
2113                "annotations": {
2114                    "kind": "read",
2115                    "side_effect_level": "read_only",
2116                    "arg_schema": {"path_params": ["path"]},
2117                    "capabilities": {"workspace": ["read_text"]},
2118                    "inline_result": true
2119                },
2120                "metadata": {"mock_output": {"text": "hello"}}
2121            }
2122        ]);
2123        let manifest =
2124            binding_manifest_from_tool_surface(&tools, BindingManifestOptions::default());
2125        let report = execute_harn_composition(
2126            CompositionExecutionRequest {
2127                run_id: "run-test".to_string(),
2128                snippet: "let file = read_file({path: \"README.md\"})\nreturn {text: file.text}"
2129                    .to_string(),
2130                manifest,
2131                ..CompositionExecutionRequest::default()
2132            },
2133            Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
2134        )
2135        .await;
2136        assert!(report.ok, "{}", report.summary);
2137        assert_eq!(report.child_calls.len(), 1);
2138        assert_eq!(report.child_results[0].status, ToolCallStatus::Completed);
2139        assert_eq!(report.run.result.unwrap()["text"], "hello");
2140    }
2141
2142    #[tokio::test(flavor = "current_thread")]
2143    async fn harn_composition_denies_mutating_binding_calls() {
2144        let tools = serde_json::json!([
2145            {
2146                "name": "write_file",
2147                "parameters": {"type": "object"},
2148                "annotations": {
2149                    "kind": "edit",
2150                    "side_effect_level": "workspace_write"
2151                }
2152            }
2153        ]);
2154        let manifest =
2155            binding_manifest_from_tool_surface(&tools, BindingManifestOptions::default());
2156        let report = execute_harn_composition(
2157            CompositionExecutionRequest {
2158                run_id: "run-deny".to_string(),
2159                snippet: "return write_file({path: \"x\", content: \"bad\"})".to_string(),
2160                manifest,
2161                ..CompositionExecutionRequest::default()
2162            },
2163            Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
2164        )
2165        .await;
2166        assert!(!report.ok);
2167        assert_eq!(
2168            report.run.failure_category,
2169            Some(CompositionFailureCategory::PolicyDenied)
2170        );
2171    }
2172
2173    #[tokio::test(flavor = "current_thread")]
2174    async fn harn_composition_records_denied_manifest_binding_as_child_failure() {
2175        let tools = serde_json::json!([
2176            {
2177                "name": "write_file",
2178                "parameters": {"type": "object"},
2179                "annotations": {
2180                    "kind": "edit",
2181                    "side_effect_level": "workspace_write"
2182                }
2183            }
2184        ]);
2185        let manifest = binding_manifest_from_tool_surface(
2186            &tools,
2187            BindingManifestOptions {
2188                include_denied: true,
2189                ..BindingManifestOptions::default()
2190            },
2191        );
2192        let report = execute_harn_composition(
2193            CompositionExecutionRequest {
2194                run_id: "run-denied-child".to_string(),
2195                snippet: "return write_file({path: \"x\", content: \"bad\"})".to_string(),
2196                manifest,
2197                ..CompositionExecutionRequest::default()
2198            },
2199            Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
2200        )
2201        .await;
2202        assert!(!report.ok);
2203        assert_eq!(report.child_calls.len(), 1);
2204        assert_eq!(report.child_results[0].status, ToolCallStatus::Failed);
2205        assert_eq!(
2206            report.child_results[0].error_category,
2207            Some(ToolCallErrorCategory::PermissionDenied)
2208        );
2209    }
2210
2211    #[tokio::test(flavor = "current_thread")]
2212    async fn harn_composition_enforces_child_call_cap() {
2213        let tools = serde_json::json!([
2214            {
2215                "name": "read_file",
2216                "parameters": {"type": "object"},
2217                "annotations": {"kind": "read", "side_effect_level": "read_only"},
2218                "metadata": {"mock_output": {"text": "hello"}}
2219            }
2220        ]);
2221        let manifest =
2222            binding_manifest_from_tool_surface(&tools, BindingManifestOptions::default());
2223        let report = execute_harn_composition(
2224            CompositionExecutionRequest {
2225                run_id: "run-cap".to_string(),
2226                snippet: "let _a = read_file({path: \"a\"})\nreturn read_file({path: \"b\"})"
2227                    .to_string(),
2228                manifest,
2229                limits: CompositionExecutionLimits {
2230                    max_operations: 1,
2231                    ..CompositionExecutionLimits::default()
2232                },
2233                ..CompositionExecutionRequest::default()
2234            },
2235            Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
2236        )
2237        .await;
2238        assert!(!report.ok);
2239        assert_eq!(
2240            report.run.failure_category,
2241            Some(CompositionFailureCategory::Timeout)
2242        );
2243        assert_eq!(report.child_calls.len(), 1);
2244    }
2245
2246    #[tokio::test(flavor = "current_thread")]
2247    async fn harn_composition_dispatcher_closure_receives_real_inputs_and_returns_outputs() {
2248        use std::cell::RefCell;
2249        let tools = serde_json::json!([
2250            {
2251                "name": "read_file",
2252                "parameters": {"type": "object", "required": ["path"]},
2253                "annotations": {"kind": "read", "side_effect_level": "read_only"},
2254            }
2255        ]);
2256        let manifest =
2257            binding_manifest_from_tool_surface(&tools, BindingManifestOptions::default());
2258
2259        struct CapturingHost {
2260            calls: RefCell<Vec<(String, Value)>>,
2261        }
2262        #[async_trait::async_trait(?Send)]
2263        impl CompositionToolHost for CapturingHost {
2264            async fn call(
2265                &self,
2266                binding: &BindingManifestEntry,
2267                input: Value,
2268            ) -> CompositionToolOutput {
2269                self.calls
2270                    .borrow_mut()
2271                    .push((binding.name.clone(), input.clone()));
2272                CompositionToolOutput::ok(serde_json::json!({
2273                    "path": input.get("path").cloned().unwrap_or(Value::Null),
2274                    "text": "real-file-bytes",
2275                }))
2276            }
2277        }
2278        let host = Rc::new(CapturingHost {
2279            calls: RefCell::new(Vec::new()),
2280        });
2281        let report = execute_harn_composition(
2282            CompositionExecutionRequest {
2283                run_id: "run-dispatch".into(),
2284                snippet: "let f = read_file({path: \"README.md\"})\nreturn f.text".into(),
2285                manifest,
2286                ..CompositionExecutionRequest::default()
2287            },
2288            host.clone(),
2289        )
2290        .await;
2291        assert!(report.ok, "{}", report.summary);
2292        assert_eq!(host.calls.borrow().len(), 1);
2293        assert_eq!(host.calls.borrow()[0].0, "read_file");
2294        assert_eq!(
2295            host.calls.borrow()[0].1.get("path").and_then(Value::as_str),
2296            Some("README.md")
2297        );
2298        assert_eq!(
2299            report.run.result.as_ref().and_then(Value::as_str),
2300            Some("real-file-bytes")
2301        );
2302    }
2303
2304    #[tokio::test(flavor = "current_thread")]
2305    async fn harn_composition_enforces_output_cap() {
2306        let report = execute_harn_composition(
2307            CompositionExecutionRequest {
2308                run_id: "run-output-cap".to_string(),
2309                snippet: "return \"0123456789\"".to_string(),
2310                limits: CompositionExecutionLimits {
2311                    max_output_bytes: 4,
2312                    ..CompositionExecutionLimits::default()
2313                },
2314                ..CompositionExecutionRequest::default()
2315            },
2316            Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
2317        )
2318        .await;
2319        assert!(!report.ok);
2320        assert!(report.summary.contains("max_output_bytes"));
2321    }
2322
2323    #[test]
2324    fn composition_report_can_be_projected_to_crystallization_trace() {
2325        let report = CompositionExecutionReport {
2326            schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
2327            ok: true,
2328            run: CompositionRunEnvelope::read_only(
2329                "run-crystal",
2330                "harn",
2331                "sha256:snippet",
2332                "sha256:manifest",
2333            ),
2334            child_calls: vec![CompositionChildCall {
2335                run_id: "run-crystal".into(),
2336                tool_call_id: "run-crystal:0".into(),
2337                tool_name: "read_file".into(),
2338                operation_index: 0,
2339                requested_side_effect_level: SideEffectLevel::ReadOnly,
2340                annotations: Some(ToolAnnotations {
2341                    capabilities: BTreeMap::from([(
2342                        "workspace".to_string(),
2343                        vec!["read_text".to_string()],
2344                    )]),
2345                    ..ToolAnnotations::default()
2346                }),
2347                raw_input: serde_json::json!({"path": "README.md"}),
2348                ..CompositionChildCall::default()
2349            }],
2350            child_results: vec![CompositionChildResult {
2351                run_id: "run-crystal".into(),
2352                tool_call_id: "run-crystal:0".into(),
2353                tool_name: "read_file".into(),
2354                operation_index: 0,
2355                status: ToolCallStatus::Completed,
2356                raw_output: Some(serde_json::json!({"text": "hello"})),
2357                ..CompositionChildResult::default()
2358            }],
2359            summary: "ok".into(),
2360        };
2361        let trace = composition_crystallization_trace(&report, &serde_json::json!({}));
2362        assert_eq!(trace["source"], "composition_run");
2363        assert_eq!(trace["actions"][0]["name"], "execute_composition");
2364        assert_eq!(trace["actions"][1]["name"], "read_file");
2365        assert_eq!(trace["replay_run"]["run_id"], "run-crystal");
2366        assert_eq!(
2367            trace["replay_run"]["effect_receipts"][0]["kind"],
2368            "composition_parent"
2369        );
2370        assert_eq!(
2371            trace["replay_run"]["effect_receipts"][1]["kind"],
2372            "composition_child"
2373        );
2374        assert_eq!(
2375            trace["replay_run"]["effect_receipts"][1]["tool_call_id"],
2376            "run-crystal:0"
2377        );
2378        assert_eq!(
2379            trace["actions"][0]["capabilities"][0],
2380            "workspace.read_text"
2381        );
2382    }
2383
2384    #[test]
2385    fn composition_report_projects_stable_agent_event_graph() {
2386        let report = CompositionExecutionReport {
2387            schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
2388            ok: true,
2389            run: CompositionRunEnvelope::read_only(
2390                "run-events",
2391                "harn",
2392                "sha256:snippet",
2393                "sha256:manifest",
2394            ),
2395            child_calls: vec![CompositionChildCall {
2396                run_id: "run-events".into(),
2397                tool_call_id: "run-events:0".into(),
2398                tool_name: "read_file".into(),
2399                operation_index: 0,
2400                ..CompositionChildCall::default()
2401            }],
2402            child_results: vec![CompositionChildResult {
2403                run_id: "run-events".into(),
2404                tool_call_id: "run-events:0".into(),
2405                tool_name: "read_file".into(),
2406                operation_index: 0,
2407                status: ToolCallStatus::Completed,
2408                ..CompositionChildResult::default()
2409            }],
2410            summary: "ok".into(),
2411        };
2412        let events = composition_report_events("session-events", &report);
2413        assert!(matches!(events[0], AgentEvent::CompositionStart { .. }));
2414        assert!(matches!(events[1], AgentEvent::CompositionChildCall { .. }));
2415        assert!(matches!(
2416            events[2],
2417            AgentEvent::CompositionChildResult { .. }
2418        ));
2419        assert!(matches!(events[3], AgentEvent::CompositionFinish { .. }));
2420    }
2421}