1use 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
47pub 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
203pub 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}