Skip to main content

harn_vm/composition/
mod.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 std::cell::RefCell;
9use std::collections::{BTreeMap, BTreeSet};
10use std::rc::Rc;
11use std::sync::Arc;
12
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16use crate::agent_events::{ToolCallErrorCategory, ToolCallStatus};
17use crate::tool_annotations::SideEffectLevel;
18use crate::value::{VmError, VmValue};
19use crate::vm::Vm;
20
21mod crystallization;
22mod events;
23mod hosts;
24mod manifest;
25mod types;
26mod typescript;
27
28#[cfg(test)]
29mod tests;
30
31pub use crystallization::composition_crystallization_trace;
32pub use events::composition_report_events;
33pub use hosts::{ClosureCompositionToolHost, StaticCompositionToolHost};
34pub use manifest::{
35    binding_manifest_from_tool_surface, binding_manifest_hash, BindingManifest,
36    BindingManifestEntry, BindingManifestOptions, BindingPolicyDisposition, BindingPolicyStatus,
37    BINDING_MANIFEST_SCHEMA_VERSION,
38};
39pub use types::{
40    CompositionChildCall, CompositionChildResult, CompositionExecutionLimits,
41    CompositionExecutionReport, CompositionExecutionRequest, CompositionFailureCategory,
42    CompositionRunEnvelope, CompositionToolHost, CompositionToolOutput,
43    COMPOSITION_EXECUTION_SCHEMA_VERSION,
44};
45pub use typescript::composition_typescript_declarations;
46
47/// Stable digest for the prompt-visible snippet body.
48pub fn composition_snippet_hash(language: &str, snippet: &str) -> String {
49    let mut hasher = Sha256::new();
50    hasher.update(b"harn.composition.snippet.v1\0");
51    hasher.update(language.as_bytes());
52    hasher.update(b"\0");
53    hasher.update(snippet.as_bytes());
54    format!("sha256:{}", hex::encode(hasher.finalize()))
55}
56
57struct ExecutionState {
58    request: CompositionExecutionRequest,
59    calls: Vec<CompositionChildCall>,
60    results: Vec<CompositionChildResult>,
61    clock: Arc<dyn harn_clock::Clock>,
62    started_ms: i64,
63}
64
65impl ExecutionState {
66    fn next_call(
67        &mut self,
68        tool_name: &str,
69        input: Value,
70    ) -> Result<(BindingManifestEntry, CompositionChildCall), VmError> {
71        if self.results.len() as u64 >= self.request.limits.max_operations {
72            return Err(VmError::Runtime(format!(
73                "composition exceeded max_operations={}",
74                self.request.limits.max_operations
75            )));
76        }
77        if let Some(timeout_ms) = self.request.limits.timeout_ms {
78            if elapsed_ms(&*self.clock, self.started_ms) > timeout_ms {
79                return Err(VmError::Runtime(format!(
80                    "composition exceeded timeout_ms={timeout_ms}"
81                )));
82            }
83        }
84        let binding = self
85            .request
86            .manifest
87            .find_by_name(tool_name)
88            .or_else(|| self.request.manifest.find_by_binding(tool_name))
89            .cloned()
90            .ok_or_else(|| {
91                VmError::Runtime(format!("composition binding '{tool_name}' not found"))
92            })?;
93        let call = self.push_call(&binding, input);
94        if binding.policy.disposition == BindingPolicyDisposition::Denied {
95            let message = format!(
96                "composition binding '{}' denied{}",
97                binding.name,
98                binding
99                    .policy
100                    .reason
101                    .as_deref()
102                    .map(|reason| format!(": {reason}"))
103                    .unwrap_or_default()
104            );
105            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
106            return Err(VmError::Runtime(message));
107        }
108        if binding.policy.disposition == BindingPolicyDisposition::Gated {
109            let message = format!(
110                "composition binding '{}' requires approval and cannot run in read-only mode",
111                binding.name
112            );
113            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
114            return Err(VmError::Runtime(message));
115        }
116        if binding.side_effect_level.rank() > self.request.requested_side_effect_ceiling.rank() {
117            let message = format!(
118                "composition binding '{}' requires side-effect level '{}' above requested ceiling '{}'",
119                binding.name,
120                binding.side_effect_level.as_str(),
121                self.request.requested_side_effect_ceiling.as_str()
122            );
123            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
124            return Err(VmError::Runtime(message));
125        }
126        Ok((binding, call))
127    }
128
129    fn push_call(&mut self, binding: &BindingManifestEntry, input: Value) -> CompositionChildCall {
130        let operation_index = self.calls.len() as u64;
131        let call = CompositionChildCall {
132            run_id: self.request.run_id.clone(),
133            tool_call_id: format!("{}:{operation_index}", self.request.run_id),
134            tool_name: binding.name.clone(),
135            operation_index,
136            annotations: Some(binding.annotations.clone()),
137            requested_side_effect_level: binding.side_effect_level,
138            policy_context: serde_json::json!({
139                "disposition": binding.policy.disposition,
140                "reason": binding.policy.reason,
141                "ceiling": self.request.requested_side_effect_ceiling,
142            }),
143            raw_input: input,
144        };
145        self.calls.push(call.clone());
146        call
147    }
148
149    fn push_failed_result(
150        &mut self,
151        call: &CompositionChildCall,
152        message: &str,
153        category: ToolCallErrorCategory,
154    ) {
155        self.results.push(CompositionChildResult {
156            run_id: call.run_id.clone(),
157            tool_call_id: call.tool_call_id.clone(),
158            tool_name: call.tool_name.clone(),
159            operation_index: call.operation_index,
160            status: ToolCallStatus::Failed,
161            raw_output: None,
162            error: Some(message.to_string()),
163            error_category: Some(category),
164            executor: Some(crate::agent_events::ToolExecutor::HarnBuiltin),
165            duration_ms: Some(0),
166            execution_duration_ms: Some(0),
167        });
168    }
169
170    fn push_result(
171        &mut self,
172        call: &CompositionChildCall,
173        output: &CompositionToolOutput,
174        elapsed_ms: u64,
175    ) {
176        if self
177            .results
178            .iter()
179            .any(|result| result.tool_call_id == call.tool_call_id)
180        {
181            return;
182        }
183        self.results.push(CompositionChildResult {
184            run_id: call.run_id.clone(),
185            tool_call_id: call.tool_call_id.clone(),
186            tool_name: call.tool_name.clone(),
187            operation_index: call.operation_index,
188            status: if output.error.is_some() {
189                ToolCallStatus::Failed
190            } else {
191                ToolCallStatus::Completed
192            },
193            raw_output: output.value.clone(),
194            error: output.error.clone(),
195            error_category: output.error_category,
196            executor: output.executor.clone(),
197            duration_ms: Some(elapsed_ms),
198            execution_duration_ms: Some(elapsed_ms),
199        });
200    }
201}
202
203/// Execute a read-only Harn-native composition snippet against a manifest.
204pub async fn execute_harn_composition(
205    mut request: CompositionExecutionRequest,
206    host: Rc<dyn CompositionToolHost>,
207) -> CompositionExecutionReport {
208    if request.run_id.trim().is_empty() {
209        request.run_id = uuid::Uuid::now_v7().to_string();
210    }
211    if request.language.trim().is_empty() {
212        request.language = "harn".to_string();
213    }
214    let manifest_hash = request
215        .manifest
216        .hash()
217        .unwrap_or_else(|_| "sha256:manifest_hash_error".to_string());
218    let snippet_hash = composition_snippet_hash(&request.language, &request.snippet);
219    let mut run = CompositionRunEnvelope::read_only(
220        request.run_id.clone(),
221        request.language.clone(),
222        snippet_hash,
223        manifest_hash,
224    );
225    let session_id = request.session_id.clone();
226    run.requested_side_effect_ceiling = request.requested_side_effect_ceiling;
227    run.metadata = request.metadata.clone();
228    if !run.metadata.is_object() {
229        run.metadata = Value::Object(serde_json::Map::new());
230    }
231    if let Some(session_id) = &session_id {
232        run.metadata["session_id"] = Value::String(session_id.clone());
233    }
234    let clock = harn_clock::RealClock::arc();
235    let started_ms = clock.monotonic_ms();
236
237    let result = if request.language != "harn" {
238        Err((
239            CompositionFailureCategory::UnsupportedLanguage,
240            format!("unsupported composition language '{}'", request.language),
241            Vec::new(),
242            Vec::new(),
243        ))
244    } else if request.requested_side_effect_ceiling.rank() > SideEffectLevel::ReadOnly.rank() {
245        Err((
246            CompositionFailureCategory::PolicyDenied,
247            "read-only composition executor refuses side-effect ceilings above read_only"
248                .to_string(),
249            Vec::new(),
250            Vec::new(),
251        ))
252    } else {
253        execute_harn_composition_inner(request, host).await
254    };
255
256    let report = match result {
257        Ok((value, stdout, calls, results)) => {
258            run.result = Some(value);
259            run.stdout = (!stdout.is_empty()).then_some(stdout);
260            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
261            CompositionExecutionReport {
262                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
263                ok: true,
264                summary: format!(
265                    "composition completed with {} child operation(s)",
266                    results.len()
267                ),
268                run,
269                child_calls: calls,
270                child_results: results,
271            }
272        }
273        Err((category, error, calls, results)) => {
274            run.failure_category = Some(category);
275            run.error = Some(error.clone());
276            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
277            CompositionExecutionReport {
278                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
279                ok: false,
280                summary: error,
281                run,
282                child_calls: calls,
283                child_results: results,
284            }
285        }
286    };
287    if let Some(session_id) = session_id {
288        events::emit_composition_report_events(&session_id, &report);
289    }
290    report
291}
292
293async fn execute_harn_composition_inner(
294    request: CompositionExecutionRequest,
295    host: Rc<dyn CompositionToolHost>,
296) -> Result<
297    (
298        Value,
299        String,
300        Vec<CompositionChildCall>,
301        Vec<CompositionChildResult>,
302    ),
303    (
304        CompositionFailureCategory,
305        String,
306        Vec<CompositionChildCall>,
307        Vec<CompositionChildResult>,
308    ),
309> {
310    let validation_source = composition_validation_source(&request.snippet);
311    let validation_program = harn_parser::parse_source(&validation_source).map_err(|error| {
312        (
313            CompositionFailureCategory::SchemaValidation,
314            format!("composition parse error: {error}"),
315            Vec::new(),
316            Vec::new(),
317        )
318    })?;
319    validate_composition_program(&validation_program, &request.manifest).map_err(|error| {
320        (
321            CompositionFailureCategory::PolicyDenied,
322            error,
323            Vec::new(),
324            Vec::new(),
325        )
326    })?;
327
328    let source = composition_source(&request.manifest, &request.snippet);
329    let program = harn_parser::parse_source(&source).map_err(|error| {
330        (
331            CompositionFailureCategory::SchemaValidation,
332            format!("composition parse error: {error}"),
333            Vec::new(),
334            Vec::new(),
335        )
336    })?;
337    let chunk = crate::Compiler::new()
338        .compile_named(&program, "main")
339        .map_err(|error| {
340            (
341                CompositionFailureCategory::SchemaValidation,
342                format!("composition compile error: {error}"),
343                Vec::new(),
344                Vec::new(),
345            )
346        })?;
347
348    let execution_clock = harn_clock::RealClock::arc();
349    let execution_started_ms = execution_clock.monotonic_ms();
350    let state = Rc::new(RefCell::new(ExecutionState {
351        request,
352        calls: Vec::new(),
353        results: Vec::new(),
354        clock: execution_clock,
355        started_ms: execution_started_ms,
356    }));
357    let mut vm = Vm::new();
358    crate::register_core_stdlib(&mut vm);
359    register_composition_call_builtin(&mut vm, state.clone(), host);
360    if let Some(timeout_ms) = state.borrow().request.limits.timeout_ms {
361        vm.push_deadline_after(std::time::Duration::from_millis(timeout_ms));
362    }
363    vm.set_source_info("composition://snippet.harn", &source);
364    match vm.execute(&chunk).await {
365        Ok(value) => {
366            let json = crate::llm::vm_value_to_json(&value);
367            let stdout = vm.output().to_string();
368            let state = state.borrow();
369            let result_size = serde_json::to_vec(&json)
370                .map(|bytes| bytes.len())
371                .unwrap_or(0);
372            let output_size = result_size.saturating_add(stdout.len());
373            if output_size as u64 > state.request.limits.max_output_bytes {
374                return Err((
375                    CompositionFailureCategory::ExecutionError,
376                    format!(
377                        "composition output exceeded max_output_bytes={}",
378                        state.request.limits.max_output_bytes
379                    ),
380                    state.calls.clone(),
381                    state.results.clone(),
382                ));
383            }
384            Ok((json, stdout, state.calls.clone(), state.results.clone()))
385        }
386        Err(error) => {
387            let state = state.borrow();
388            let category = if error.to_string().contains("denied")
389                || error.to_string().contains("side-effect")
390                || error.to_string().contains("approval")
391            {
392                CompositionFailureCategory::PolicyDenied
393            } else if error.to_string().contains("Deadline exceeded")
394                || error.to_string().contains("max_operations")
395                || error.to_string().contains("timeout_ms")
396                || error.to_string().contains("max_output_bytes")
397            {
398                CompositionFailureCategory::Timeout
399            } else if state
400                .results
401                .iter()
402                .any(|result| result.status == ToolCallStatus::Failed)
403            {
404                CompositionFailureCategory::ChildToolError
405            } else {
406                CompositionFailureCategory::ExecutionError
407            };
408            Err((
409                category,
410                error.to_string(),
411                state.calls.clone(),
412                state.results.clone(),
413            ))
414        }
415    }
416}
417
418fn register_composition_call_builtin(
419    vm: &mut Vm,
420    state: Rc<RefCell<ExecutionState>>,
421    host: Rc<dyn CompositionToolHost>,
422) {
423    vm.register_async_builtin("__composition_call", move |args| {
424        let state = state.clone();
425        let host = host.clone();
426        async move {
427            let tool_name = args
428                .first()
429                .map(VmValue::display)
430                .ok_or_else(|| VmError::Runtime("__composition_call: missing tool name".into()))?;
431            let input = args
432                .get(1)
433                .map(crate::llm::vm_value_to_json)
434                .unwrap_or_else(|| serde_json::json!({}));
435            let (binding, call, clock) = {
436                let mut state = state.borrow_mut();
437                let (binding, call) = state.next_call(&tool_name, input.clone())?;
438                (binding, call, state.clock.clone())
439            };
440            let started_ms = clock.monotonic_ms();
441            let output = host.call(&binding, input).await;
442            {
443                let mut state = state.borrow_mut();
444                state.push_result(&call, &output, elapsed_ms(&*clock, started_ms));
445            }
446            if let Some(error) = output.error {
447                return Err(VmError::Runtime(error));
448            }
449            Ok(crate::json_to_vm_value(
450                &output.value.unwrap_or(Value::Null),
451            ))
452        }
453    });
454}
455
456fn elapsed_ms(clock: &dyn harn_clock::Clock, started_ms: i64) -> u64 {
457    clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64
458}
459
460fn composition_validation_source(snippet: &str) -> String {
461    let mut source = String::from("pipeline main() {\n");
462    source.push_str(snippet);
463    if !snippet.ends_with('\n') {
464        source.push('\n');
465    }
466    source.push_str("}\n");
467    source
468}
469
470fn composition_source(manifest: &BindingManifest, snippet: &str) -> String {
471    let mut source = String::new();
472    for binding in &manifest.bindings {
473        source.push_str(&format!(
474            "fn {}(args = {{}}) {{ return __composition_call(\"{}\", args) }}\n",
475            binding.binding,
476            escape_harn_string(&binding.name)
477        ));
478    }
479    source.push_str("pipeline main() {\n");
480    source.push_str(snippet);
481    if !snippet.ends_with('\n') {
482        source.push('\n');
483    }
484    source.push_str("}\n");
485    source
486}
487
488fn escape_harn_string(value: &str) -> String {
489    value.replace('\\', "\\\\").replace('"', "\\\"")
490}
491
492fn validate_composition_program(
493    program: &[harn_parser::SNode],
494    manifest: &BindingManifest,
495) -> Result<(), String> {
496    use harn_parser::visit::walk_program;
497    use harn_parser::Node;
498
499    let bindings = manifest
500        .bindings
501        .iter()
502        .map(|entry| entry.binding.clone())
503        .collect::<BTreeSet<_>>();
504    let mut local_functions = BTreeSet::from(["__composition_call".to_string()]);
505    walk_program(program, &mut |node| {
506        if let Node::FnDecl { name, .. } = &node.node {
507            local_functions.insert(name.clone());
508        }
509    });
510
511    let mut error = None;
512    walk_program(program, &mut |node| {
513        if error.is_some() {
514            return;
515        }
516        match &node.node {
517            Node::ImportDecl { .. } | Node::SelectiveImport { .. } => {
518                error = Some("composition snippets cannot import modules".to_string());
519            }
520            Node::SpawnExpr { .. } | Node::Parallel { .. } => {
521                error = Some("composition snippets cannot spawn or parallelize work".to_string());
522            }
523            Node::HitlExpr { .. } => {
524                error = Some("composition snippets cannot request HITL directly".to_string());
525            }
526            Node::CostRoute { .. } => {
527                error = Some("composition snippets cannot open LLM routing blocks".to_string());
528            }
529            Node::FunctionCall { name, .. } => {
530                if DENIED_COMPOSITION_CALLS.contains(&name.as_str()) && !bindings.contains(name) {
531                    error = Some(format!("composition snippets cannot call `{name}`"));
532                } else if !bindings.contains(name)
533                    && !local_functions.contains(name)
534                    && !PURE_COMPOSITION_CALLS.contains(&name.as_str())
535                {
536                    error = Some(format!(
537                        "composition call target `{name}` is not a manifest binding or pure helper"
538                    ));
539                }
540            }
541            _ => {}
542        }
543    });
544    error.map_or(Ok(()), Err)
545}
546
547const DENIED_COMPOSITION_CALLS: &[&str] = &[
548    "append_file",
549    "ask_user",
550    "connector_call",
551    "copy_file",
552    "delete_file",
553    "dual_control",
554    "escalate_to",
555    "event_log_emit",
556    "event_log.emit",
557    "exec",
558    "host_call",
559    "host_tool_call",
560    "http_delete",
561    "http_download",
562    "http_get",
563    "http_patch",
564    "http_post",
565    "http_put",
566    "http_request",
567    "llm_call",
568    "mcp_call",
569    "mcp_connect",
570    "pg_execute",
571    "pg_query",
572    "request_approval",
573    "secret_get",
574    "write_file",
575];
576
577const PURE_COMPOSITION_CALLS: &[&str] = &[
578    "Ok",
579    "Err",
580    "abs",
581    "assert",
582    "assert_eq",
583    "assert_ne",
584    "base64_decode",
585    "base64_encode",
586    "ceil",
587    "contains",
588    "dedup_by",
589    "dirname",
590    "entries",
591    "ends_with",
592    "flat_map",
593    "floor",
594    "format",
595    "group_by",
596    "hash_value",
597    "hex_decode",
598    "hex_encode",
599    "is_err",
600    "is_ok",
601    "join",
602    "jq",
603    "jq_first",
604    "json_extract",
605    "json_parse",
606    "json_pointer",
607    "json_stringify",
608    "keys",
609    "len",
610    "lower",
611    "parse_float_or",
612    "parse_int_or",
613    "split",
614    "starts_with",
615    "to_float",
616    "to_int",
617    "to_string",
618    "trim",
619    "upper",
620    "values",
621];
622
623pub fn composition_search_examples(query: &str, limit: usize) -> Value {
624    let mut examples = vec![
625        serde_json::json!({
626            "id": "read-summarize",
627            "title": "Read two files and return a compact summary",
628            "language": "harn",
629            "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}",
630            "required_side_effect_level": "read_only",
631            "tools": ["read_file"]
632        }),
633        serde_json::json!({
634            "id": "search-then-read",
635            "title": "Search first, then read the best candidate",
636            "language": "harn",
637            "snippet": "let hits = search({query: \"CompositionRunEnvelope\"})\nreturn hits",
638            "required_side_effect_level": "read_only",
639            "tools": ["search"]
640        }),
641    ];
642    if !query.trim().is_empty() {
643        let q = query.to_ascii_lowercase();
644        examples.retain(|example| {
645            example
646                .to_string()
647                .to_ascii_lowercase()
648                .contains(q.as_str())
649        });
650    }
651    examples.truncate(limit.max(1));
652    Value::Array(examples)
653}
654
655pub fn register_composition_builtins(vm: &mut Vm) {
656    vm.register_builtin("composition_binding_manifest", |args, _out| {
657        let tools = args
658            .first()
659            .map(crate::llm::vm_value_to_json)
660            .unwrap_or(Value::Null);
661        let options_json = args
662            .get(1)
663            .map(crate::llm::vm_value_to_json)
664            .unwrap_or(Value::Null);
665        let mut options = BindingManifestOptions::default();
666        if let Some(ceiling) = options_json
667            .get("side_effect_ceiling")
668            .and_then(Value::as_str)
669        {
670            options.side_effect_ceiling = SideEffectLevel::parse(ceiling);
671        }
672        if let Some(include_denied) = options_json.get("include_denied").and_then(Value::as_bool) {
673            options.include_denied = include_denied;
674        }
675        options.denied_tools = string_set_option(&options_json, "denied_tools");
676        options.gated_tools = string_set_option(&options_json, "gated_tools");
677        let manifest = binding_manifest_from_tool_surface(&tools, options);
678        let value = if options_json.get("form").and_then(Value::as_str) == Some("compact") {
679            manifest.to_compact_value()
680        } else {
681            manifest.to_value()
682        };
683        Ok(crate::json_to_vm_value(&value))
684    });
685
686    vm.register_builtin("composition_search_examples", |args, _out| {
687        let query = args.first().map(VmValue::display).unwrap_or_default();
688        let limit = args
689            .get(1)
690            .and_then(|value| match value {
691                VmValue::Int(n) => Some((*n).max(1) as usize),
692                _ => None,
693            })
694            .unwrap_or(10);
695        Ok(crate::json_to_vm_value(&composition_search_examples(
696            &query, limit,
697        )))
698    });
699
700    vm.register_builtin("composition_typescript_declarations", |args, _out| {
701        let manifest_value = args
702            .first()
703            .map(crate::llm::vm_value_to_json)
704            .ok_or_else(|| {
705                VmError::Runtime("composition_typescript_declarations: manifest is required".into())
706            })?;
707        let manifest: BindingManifest =
708            serde_json::from_value(manifest_value).map_err(|error| {
709                VmError::Runtime(format!(
710                    "composition_typescript_declarations: invalid manifest: {error}"
711                ))
712            })?;
713        Ok(VmValue::String(Rc::from(
714            composition_typescript_declarations(&manifest),
715        )))
716    });
717
718    vm.register_builtin("composition_crystallization_trace", |args, _out| {
719        let report_value = args
720            .first()
721            .map(crate::llm::vm_value_to_json)
722            .ok_or_else(|| {
723                VmError::Runtime("composition_crystallization_trace: report is required".into())
724            })?;
725        let report: CompositionExecutionReport =
726            serde_json::from_value(report_value).map_err(|error| {
727                VmError::Runtime(format!(
728                    "composition_crystallization_trace: invalid report: {error}"
729                ))
730            })?;
731        let options = args
732            .get(1)
733            .map(crate::llm::vm_value_to_json)
734            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
735        Ok(crate::json_to_vm_value(&composition_crystallization_trace(
736            &report, &options,
737        )))
738    });
739
740    vm.register_async_builtin("composition_execute", |args| async move {
741        let snippet = args
742            .first()
743            .map(VmValue::display)
744            .ok_or_else(|| VmError::Runtime("composition_execute: snippet is required".into()))?;
745        let manifest_value = args
746            .get(1)
747            .map(crate::llm::vm_value_to_json)
748            .ok_or_else(|| VmError::Runtime("composition_execute: manifest is required".into()))?;
749        let dispatcher = args.get(2).and_then(|value| match value {
750            VmValue::Closure(closure) => Some((**closure).clone()),
751            VmValue::Dict(dict) => match dict.get("dispatcher") {
752                Some(VmValue::Closure(closure)) => Some((**closure).clone()),
753                _ => None,
754            },
755            _ => None,
756        });
757        let mut request = CompositionExecutionRequest {
758            snippet,
759            manifest: serde_json::from_value(manifest_value).map_err(|error| {
760                VmError::Runtime(format!("composition_execute: invalid manifest: {error}"))
761            })?,
762            ..CompositionExecutionRequest::default()
763        };
764        if let Some(options) = args.get(2).map(crate::llm::vm_value_to_json) {
765            if let Some(session_id) = options.get("session_id").and_then(Value::as_str) {
766                request.session_id = Some(session_id.to_string());
767            }
768            if let Some(run_id) = options.get("run_id").and_then(Value::as_str) {
769                request.run_id = run_id.to_string();
770            }
771            if let Some(max_operations) = options.get("max_operations").and_then(Value::as_u64) {
772                request.limits.max_operations = max_operations;
773            }
774            if let Some(timeout_ms) = options.get("timeout_ms").and_then(Value::as_u64) {
775                request.limits.timeout_ms = Some(timeout_ms);
776            }
777            if let Some(max_output_bytes) = options.get("max_output_bytes").and_then(Value::as_u64)
778            {
779                request.limits.max_output_bytes = max_output_bytes;
780            }
781        }
782        let host: Rc<dyn CompositionToolHost> = match dispatcher {
783            Some(closure) => {
784                let outer_vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
785                    VmError::Runtime(
786                        "composition_execute: dispatcher requires an async builtin VM context"
787                            .into(),
788                    )
789                })?;
790                Rc::new(ClosureCompositionToolHost::new(closure, outer_vm))
791            }
792            None => Rc::new(StaticCompositionToolHost::new(BTreeMap::new())),
793        };
794        let report = execute_harn_composition(request, host).await;
795        Ok(crate::json_to_vm_value(
796            &serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"ok": false})),
797        ))
798    });
799}
800
801fn string_set_option(value: &Value, key: &str) -> BTreeSet<String> {
802    value
803        .get(key)
804        .and_then(Value::as_array)
805        .map(|items| {
806            items
807                .iter()
808                .filter_map(Value::as_str)
809                .map(ToOwned::to_owned)
810                .collect()
811        })
812        .unwrap_or_default()
813}