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::collections::{BTreeMap, BTreeSet, HashMap};
9use std::sync::Arc;
10use std::time::Duration;
11
12use futures::stream::{FuturesUnordered, StreamExt};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15use tokio::sync::{OwnedSemaphorePermit, Semaphore};
16
17use crate::agent_events::{ToolCallErrorCategory, ToolCallStatus};
18use crate::tool_annotations::SideEffectLevel;
19use crate::value::{VmError, VmValue};
20use crate::vm::Vm;
21
22mod builtins;
23mod crystallization;
24mod events;
25mod harn_api;
26mod hosts;
27mod manifest;
28mod state;
29mod types;
30mod typescript;
31
32#[cfg(test)]
33mod tests;
34
35pub use builtins::register_composition_builtins;
36pub use crystallization::composition_crystallization_trace;
37pub use events::composition_report_events;
38pub use harn_api::composition_harn_api;
39pub use hosts::{ClosureCompositionToolHost, StaticCompositionToolHost};
40pub use manifest::{
41    binding_manifest_from_tool_surface, binding_manifest_hash, BindingManifest,
42    BindingManifestEntry, BindingManifestOptions, BindingPolicyDisposition, BindingPolicyStatus,
43    BINDING_MANIFEST_SCHEMA_VERSION,
44};
45pub use state::{
46    CompositionStateBinding, CompositionStateError, CompositionStateErrorCode,
47    COMPOSITION_STATE_CAPABILITY, COMPOSITION_STATE_SCHEMA_VERSION,
48};
49pub use types::{
50    CompositionChildCall, CompositionChildResult, CompositionExecutionLimits,
51    CompositionExecutionReport, CompositionExecutionRequest, CompositionFailureCategory,
52    CompositionMcpPolicy, CompositionRetryPolicy, CompositionRunEnvelope, CompositionToolHost,
53    CompositionToolOutput, COMPOSITION_EXECUTION_SCHEMA_VERSION,
54};
55pub use typescript::composition_typescript_declarations;
56
57/// Stable digest for the prompt-visible snippet body.
58pub fn composition_snippet_hash(language: &str, snippet: &str) -> String {
59    let mut hasher = Sha256::new();
60    hasher.update(b"harn.composition.snippet.v1\0");
61    hasher.update(language.as_bytes());
62    hasher.update(b"\0");
63    hasher.update(snippet.as_bytes());
64    format!("sha256:{}", hex::encode(hasher.finalize()))
65}
66
67struct ExecutionState {
68    request: CompositionExecutionRequest,
69    calls: Vec<CompositionChildCall>,
70    results: Vec<CompositionChildResult>,
71    clock: Arc<dyn harn_clock::Clock>,
72    started_ms: i64,
73}
74
75impl ExecutionState {
76    fn check_operation_budget(&self) -> Result<(), VmError> {
77        if self.results.len() as u64 >= self.request.limits.max_operations {
78            return Err(VmError::Runtime(format!(
79                "composition exceeded max_operations={}",
80                self.request.limits.max_operations
81            )));
82        }
83        if let Some(timeout_ms) = self.request.limits.timeout_ms {
84            if elapsed_ms(&*self.clock, self.started_ms) > timeout_ms {
85                return Err(VmError::Runtime(format!(
86                    "composition exceeded timeout_ms={timeout_ms}"
87                )));
88            }
89        }
90        Ok(())
91    }
92
93    fn next_call(
94        &mut self,
95        tool_name: &str,
96        input: Value,
97    ) -> Result<(BindingManifestEntry, CompositionChildCall), VmError> {
98        self.check_operation_budget()?;
99        let binding = self
100            .request
101            .manifest
102            .find_by_name(tool_name)
103            .or_else(|| self.request.manifest.find_by_binding(tool_name))
104            .cloned()
105            .ok_or_else(|| {
106                VmError::Runtime(format!("composition binding '{tool_name}' not found"))
107            })?;
108        let call = self.push_call(&binding, input);
109        if binding.policy.disposition == BindingPolicyDisposition::Denied {
110            let message = format!(
111                "composition binding '{}' denied{}",
112                binding.name,
113                binding
114                    .policy
115                    .reason
116                    .as_deref()
117                    .map(|reason| format!(": {reason}"))
118                    .unwrap_or_default()
119            );
120            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
121            return Err(VmError::Runtime(message));
122        }
123        if binding.policy.disposition == BindingPolicyDisposition::Gated {
124            let message = format!(
125                "composition binding '{}' requires approval and cannot run in read-only mode",
126                binding.name
127            );
128            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
129            return Err(VmError::Runtime(message));
130        }
131        if binding.side_effect_level.rank() > self.request.requested_side_effect_ceiling.rank() {
132            let message = format!(
133                "composition binding '{}' requires side-effect level '{}' above requested ceiling '{}'",
134                binding.name,
135                binding.side_effect_level.as_str(),
136                self.request.requested_side_effect_ceiling.as_str()
137            );
138            self.push_failed_result(&call, &message, ToolCallErrorCategory::PermissionDenied);
139            return Err(VmError::Runtime(message));
140        }
141        Ok((binding, call))
142    }
143
144    fn next_state_call(
145        &mut self,
146        operation: &str,
147        input: Value,
148        tool_window_id: Option<&str>,
149    ) -> Result<(BindingManifestEntry, CompositionChildCall), VmError> {
150        self.check_operation_budget()?;
151        let binding = state::binding_entry(operation);
152        let mut call = self.push_call(&binding, input);
153        call.policy_context = serde_json::json!({
154            "disposition": "allowed",
155            "grant": "manifest.state",
156            "capability_class": state::COMPOSITION_STATE_CAPABILITY,
157            "operation": operation,
158            "tool_window_id": tool_window_id,
159            "ceiling": self.request.requested_side_effect_ceiling,
160        });
161        if let Some(stored) = self.calls.last_mut() {
162            stored.policy_context.clone_from(&call.policy_context);
163        }
164        Ok((binding, call))
165    }
166
167    fn push_call(&mut self, binding: &BindingManifestEntry, input: Value) -> CompositionChildCall {
168        let operation_index = self.calls.len() as u64;
169        let call = CompositionChildCall {
170            run_id: self.request.run_id.clone(),
171            tool_call_id: format!("{}:{operation_index}", self.request.run_id),
172            tool_name: binding.name.clone(),
173            operation_index,
174            annotations: Some(binding.annotations.clone()),
175            requested_side_effect_level: binding.side_effect_level,
176            policy_context: serde_json::json!({
177                "disposition": binding.policy.disposition,
178                "reason": binding.policy.reason,
179                "ceiling": self.request.requested_side_effect_ceiling,
180            }),
181            raw_input: input,
182        };
183        self.calls.push(call.clone());
184        call
185    }
186
187    fn push_failed_result(
188        &mut self,
189        call: &CompositionChildCall,
190        message: &str,
191        category: ToolCallErrorCategory,
192    ) {
193        self.results.push(CompositionChildResult {
194            run_id: call.run_id.clone(),
195            tool_call_id: call.tool_call_id.clone(),
196            tool_name: call.tool_name.clone(),
197            operation_index: call.operation_index,
198            status: ToolCallStatus::Failed,
199            raw_output: None,
200            error: Some(message.to_string()),
201            error_category: Some(category),
202            error_details: None,
203            executor: Some(crate::agent_events::ToolExecutor::HarnBuiltin),
204            duration_ms: Some(0),
205            execution_duration_ms: Some(0),
206            attempt: 1,
207            retry_attempts: 0,
208            retry_errors: Vec::new(),
209            retry_delays_ms: Vec::new(),
210        });
211    }
212
213    fn push_result(
214        &mut self,
215        call: &CompositionChildCall,
216        outcome: &CompositionDispatchOutcome,
217        elapsed_ms: u64,
218    ) {
219        if self
220            .results
221            .iter()
222            .any(|result| result.tool_call_id == call.tool_call_id)
223        {
224            return;
225        }
226        self.results.push(CompositionChildResult {
227            run_id: call.run_id.clone(),
228            tool_call_id: call.tool_call_id.clone(),
229            tool_name: call.tool_name.clone(),
230            operation_index: call.operation_index,
231            status: if outcome.output.error.is_some() {
232                ToolCallStatus::Failed
233            } else {
234                ToolCallStatus::Completed
235            },
236            raw_output: outcome.output.value.clone(),
237            error: outcome.output.error.clone(),
238            error_category: outcome.output.error_category,
239            error_details: outcome.error_details.clone(),
240            executor: outcome.output.executor.clone(),
241            duration_ms: Some(elapsed_ms),
242            execution_duration_ms: Some(elapsed_ms),
243            attempt: outcome.attempt,
244            retry_attempts: outcome.retry_attempts,
245            retry_errors: outcome.retry_errors.clone(),
246            retry_delays_ms: outcome.retry_delays_ms.clone(),
247        });
248    }
249}
250
251#[derive(Clone)]
252struct CompositionRuntime {
253    state: Arc<parking_lot::Mutex<ExecutionState>>,
254    host: Arc<dyn CompositionToolHost>,
255    bulkheads: Arc<CompositionBulkheads>,
256    state_binding: Option<CompositionStateBinding>,
257    state_scope: Option<state::CompositionStateScope>,
258}
259
260struct CompositionBulkheads {
261    global: Arc<Semaphore>,
262    per_server: parking_lot::Mutex<HashMap<String, Arc<Semaphore>>>,
263    per_server_limit: usize,
264}
265
266impl CompositionBulkheads {
267    fn new(limits: &CompositionExecutionLimits) -> Self {
268        Self {
269            global: Arc::new(Semaphore::new(
270                limits
271                    .max_concurrent_operations
272                    .clamp(1, Semaphore::MAX_PERMITS),
273            )),
274            per_server: parking_lot::Mutex::new(HashMap::new()),
275            per_server_limit: limits
276                .max_concurrent_per_server
277                .clamp(1, Semaphore::MAX_PERMITS),
278        }
279    }
280
281    async fn acquire(
282        &self,
283        binding: &BindingManifestEntry,
284    ) -> Result<(OwnedSemaphorePermit, Option<OwnedSemaphorePermit>), VmError> {
285        let global = self
286            .global
287            .clone()
288            .acquire_owned()
289            .await
290            .map_err(|_| VmError::Runtime("composition bulkhead closed".to_string()))?;
291        let server = mcp_server_name(binding);
292        let per_server = match server {
293            Some(server) => {
294                let semaphore = {
295                    let mut semaphores = self.per_server.lock();
296                    semaphores
297                        .entry(server)
298                        .or_insert_with(|| Arc::new(Semaphore::new(self.per_server_limit)))
299                        .clone()
300                };
301                Some(semaphore.acquire_owned().await.map_err(|_| {
302                    VmError::Runtime("composition per-server bulkhead closed".to_string())
303                })?)
304            }
305            None => None,
306        };
307        Ok((global, per_server))
308    }
309}
310
311struct CompositionDispatchOutcome {
312    output: CompositionToolOutput,
313    error_details: Option<Value>,
314    attempt: u32,
315    retry_attempts: u32,
316    retry_errors: Vec<String>,
317    retry_delays_ms: Vec<u64>,
318}
319
320impl CompositionDispatchOutcome {
321    fn state_ok(value: Value) -> Self {
322        Self {
323            output: CompositionToolOutput::ok(value),
324            error_details: None,
325            attempt: 1,
326            retry_attempts: 0,
327            retry_errors: Vec::new(),
328            retry_delays_ms: Vec::new(),
329        }
330    }
331
332    fn state_error(error: &CompositionStateError) -> Self {
333        Self {
334            output: CompositionToolOutput::error(
335                error.message.clone(),
336                ToolCallErrorCategory::SchemaValidation,
337            ),
338            error_details: Some(error.to_value()),
339            attempt: 1,
340            retry_attempts: 0,
341            retry_errors: Vec::new(),
342            retry_delays_ms: Vec::new(),
343        }
344    }
345}
346
347/// Execute a read-only Harn-native composition snippet against a manifest.
348pub async fn execute_harn_composition(
349    mut request: CompositionExecutionRequest,
350    host: Arc<dyn CompositionToolHost>,
351) -> CompositionExecutionReport {
352    if request.run_id.trim().is_empty() {
353        request.run_id = uuid::Uuid::now_v7().to_string();
354    }
355    if request.language.trim().is_empty() {
356        request.language = "harn".to_string();
357    }
358    let manifest_hash = request
359        .manifest
360        .hash()
361        .unwrap_or_else(|_| "sha256:manifest_hash_error".to_string());
362    let snippet_hash = composition_snippet_hash(&request.language, &request.snippet);
363    let mut run = CompositionRunEnvelope::read_only(
364        request.run_id.clone(),
365        request.language.clone(),
366        snippet_hash,
367        manifest_hash,
368    );
369    let session_id = request.session_id.clone();
370    run.requested_side_effect_ceiling = request.requested_side_effect_ceiling;
371    run.metadata = request.metadata.clone();
372    if !run.metadata.is_object() {
373        run.metadata = Value::Object(serde_json::Map::new());
374    }
375    if let Some(session_id) = &session_id {
376        run.metadata["session_id"] = Value::String(session_id.clone());
377    }
378    let clock = harn_clock::RealClock::arc();
379    let started_ms = clock.monotonic_ms();
380
381    let result = if request.language != "harn" {
382        Err((
383            CompositionFailureCategory::UnsupportedLanguage,
384            format!("unsupported composition language '{}'", request.language),
385            Vec::new(),
386            Vec::new(),
387        ))
388    } else if request.requested_side_effect_ceiling.rank() > SideEffectLevel::ReadOnly.rank() {
389        Err((
390            CompositionFailureCategory::PolicyDenied,
391            "read-only composition executor refuses side-effect ceilings above read_only"
392                .to_string(),
393            Vec::new(),
394            Vec::new(),
395        ))
396    } else {
397        execute_harn_composition_inner(request, host).await
398    };
399
400    let report = match result {
401        Ok((value, stdout, calls, results)) => {
402            run.result = Some(value);
403            run.stdout = (!stdout.is_empty()).then_some(stdout);
404            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
405            CompositionExecutionReport {
406                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
407                ok: true,
408                summary: format!(
409                    "composition completed with {} child operation(s)",
410                    results.len()
411                ),
412                run,
413                child_calls: calls,
414                child_results: results,
415            }
416        }
417        Err((category, error, calls, results)) => {
418            run.failure_category = Some(category);
419            run.error = Some(error.clone());
420            run.duration_ms = Some(elapsed_ms(&*clock, started_ms));
421            CompositionExecutionReport {
422                schema_version: COMPOSITION_EXECUTION_SCHEMA_VERSION,
423                ok: false,
424                summary: error,
425                run,
426                child_calls: calls,
427                child_results: results,
428            }
429        }
430    };
431    if let Some(session_id) = session_id {
432        events::emit_composition_report_events(&session_id, &report);
433    }
434    report
435}
436
437async fn execute_harn_composition_inner(
438    request: CompositionExecutionRequest,
439    host: Arc<dyn CompositionToolHost>,
440) -> Result<
441    (
442        Value,
443        String,
444        Vec<CompositionChildCall>,
445        Vec<CompositionChildResult>,
446    ),
447    (
448        CompositionFailureCategory,
449        String,
450        Vec<CompositionChildCall>,
451        Vec<CompositionChildResult>,
452    ),
453> {
454    let state_binding = request.manifest.state.clone();
455    if let Some(binding) = &state_binding {
456        binding.validate().map_err(|error| {
457            (
458                CompositionFailureCategory::SchemaValidation,
459                format!("invalid composition state binding: {}", error.message),
460                Vec::new(),
461                Vec::new(),
462            )
463        })?;
464        if request
465            .manifest
466            .bindings
467            .iter()
468            .any(|entry| entry.binding == "state" || entry.binding.starts_with("state."))
469        {
470            return Err((
471                CompositionFailureCategory::SchemaValidation,
472                "composition state binding conflicts with a tool binding named `state`".to_string(),
473                Vec::new(),
474                Vec::new(),
475            ));
476        }
477    }
478    let state_scope = state_binding.as_ref().and_then(|_| {
479        request
480            .session_id
481            .as_deref()
482            .filter(|session_id| !session_id.trim().is_empty())
483            .and_then(|session_id| {
484                request.manifest.hash().ok().map(|window_id| {
485                    state::CompositionStateScope::new(session_id.to_string(), window_id)
486                })
487            })
488    });
489    let validation_source = composition_validation_source(&request.snippet);
490    let validation_program = harn_parser::parse_source(&validation_source).map_err(|error| {
491        (
492            CompositionFailureCategory::SchemaValidation,
493            format!("composition parse error: {error}"),
494            Vec::new(),
495            Vec::new(),
496        )
497    })?;
498    validate_composition_program(&validation_program, &request.manifest).map_err(|error| {
499        (
500            CompositionFailureCategory::PolicyDenied,
501            error,
502            Vec::new(),
503            Vec::new(),
504        )
505    })?;
506
507    let source = composition_source(&request.manifest, &request.snippet);
508    let program = harn_parser::parse_source(&source).map_err(|error| {
509        (
510            CompositionFailureCategory::SchemaValidation,
511            format!("composition parse error: {error}"),
512            Vec::new(),
513            Vec::new(),
514        )
515    })?;
516    let chunk = crate::Compiler::new()
517        .compile_named(&program, "main")
518        .map_err(|error| {
519            (
520                CompositionFailureCategory::SchemaValidation,
521                format!("composition compile error: {error}"),
522                Vec::new(),
523                Vec::new(),
524            )
525        })?;
526
527    let execution_clock = harn_clock::RealClock::arc();
528    let execution_started_ms = execution_clock.monotonic_ms();
529    let state = Arc::new(parking_lot::Mutex::new(ExecutionState {
530        request,
531        calls: Vec::new(),
532        results: Vec::new(),
533        clock: execution_clock,
534        started_ms: execution_started_ms,
535    }));
536    let mut vm = Vm::new();
537    crate::register_core_stdlib(&mut vm);
538    // Composition snippets receive the standard root entrypoint argument but
539    // intentionally have no ambient authority. Their only egress is the
540    // manifest-bound `__composition_call` adapter installed below.
541    vm.set_harness(crate::Harness::null());
542    let limits = state.lock().request.limits.clone();
543    let runtime = CompositionRuntime {
544        state: state.clone(),
545        host,
546        bulkheads: Arc::new(CompositionBulkheads::new(&limits)),
547        state_binding,
548        state_scope,
549    };
550    register_composition_call_builtin(&mut vm, runtime.clone());
551    if state.lock().request.manifest.state.is_some() {
552        register_composition_state_builtin(&mut vm, runtime.clone());
553    }
554    register_composition_map_bounded_builtin(&mut vm, runtime);
555    if let Some(timeout_ms) = state.lock().request.limits.timeout_ms {
556        vm.push_deadline_after(std::time::Duration::from_millis(timeout_ms));
557    }
558    vm.set_source_info("composition://snippet.harn", &source);
559    match vm.execute(&chunk).await {
560        Ok(value) => {
561            let json = crate::llm::vm_value_to_json(&value);
562            let stdout = vm.output().to_string();
563            let state = state.lock();
564            let result_size = serde_json::to_vec(&json)
565                .map(|bytes| bytes.len())
566                .unwrap_or(0);
567            let output_size = result_size.saturating_add(stdout.len());
568            if output_size as u64 > state.request.limits.max_output_bytes {
569                return Err((
570                    CompositionFailureCategory::ExecutionError,
571                    format!(
572                        "composition output exceeded max_output_bytes={}",
573                        state.request.limits.max_output_bytes
574                    ),
575                    state.calls.clone(),
576                    state.results.clone(),
577                ));
578            }
579            Ok((json, stdout, state.calls.clone(), state.results.clone()))
580        }
581        Err(error) => {
582            let state = state.lock();
583            let category = if error.to_string().contains("denied")
584                || error.to_string().contains("side-effect")
585                || error.to_string().contains("approval")
586            {
587                CompositionFailureCategory::PolicyDenied
588            } else if error.to_string().contains("Deadline exceeded")
589                || error.to_string().contains("max_operations")
590                || error.to_string().contains("timeout_ms")
591                || error.to_string().contains("max_output_bytes")
592            {
593                CompositionFailureCategory::Timeout
594            } else if state
595                .results
596                .iter()
597                .any(|result| result.status == ToolCallStatus::Failed)
598            {
599                CompositionFailureCategory::ChildToolError
600            } else {
601                CompositionFailureCategory::ExecutionError
602            };
603            Err((
604                category,
605                error.to_string(),
606                state.calls.clone(),
607                state.results.clone(),
608            ))
609        }
610    }
611}
612
613fn register_composition_call_builtin(vm: &mut Vm, runtime: CompositionRuntime) {
614    vm.register_async_builtin("__composition_call", move |_ctx, args| {
615        let runtime = runtime.clone();
616        async move {
617            let tool_name = args
618                .first()
619                .map(VmValue::display)
620                .ok_or_else(|| VmError::Runtime("__composition_call: missing tool name".into()))?;
621            let input = args
622                .get(1)
623                .map(crate::llm::vm_value_to_json)
624                .unwrap_or_else(|| serde_json::json!({}));
625            let (binding, call, clock) = {
626                let mut state = runtime.state.lock();
627                let (binding, call) = state.next_call(&tool_name, input.clone())?;
628                (binding, call, state.clock.clone())
629            };
630            let started_ms = clock.monotonic_ms();
631            let outcome = dispatch_binding_with_policy(&runtime, &binding, input).await?;
632            {
633                let mut state = runtime.state.lock();
634                state.push_result(&call, &outcome, elapsed_ms(&*clock, started_ms));
635            }
636            if let Some(error) = outcome.output.error {
637                return Err(VmError::Runtime(error));
638            }
639            Ok(crate::json_to_vm_value(
640                &outcome.output.value.unwrap_or(Value::Null),
641            ))
642        }
643    });
644}
645
646fn register_composition_state_builtin(vm: &mut Vm, runtime: CompositionRuntime) {
647    vm.register_async_builtin("__composition_state", move |_ctx, args| {
648        let runtime = runtime.clone();
649        async move {
650            let operation = args
651                .first()
652                .and_then(|value| match value {
653                    VmValue::String(value) => Some(value.to_string()),
654                    _ => None,
655                })
656                .ok_or_else(|| {
657                    VmError::Runtime("__composition_state: missing state operation".into())
658                })?;
659            let raw_key = args.get(1).map(crate::llm::vm_value_to_json);
660            let raw_value = args.get(2).map(crate::llm::vm_value_to_json);
661            let input = match operation.as_str() {
662                "put" => serde_json::json!({
663                    "key": raw_key.clone().unwrap_or(Value::Null),
664                    "value": raw_value.unwrap_or(Value::Null),
665                }),
666                "get" | "delete" => serde_json::json!({
667                    "key": raw_key.clone().unwrap_or(Value::Null),
668                }),
669                _ => serde_json::json!({}),
670            };
671            let tool_window_id = runtime
672                .state_scope
673                .as_ref()
674                .map(state::CompositionStateScope::tool_window_id);
675            let (binding, call, clock) = {
676                let mut execution = runtime.state.lock();
677                let (binding, call) =
678                    execution.next_state_call(&operation, input.clone(), tool_window_id)?;
679                (binding, call, execution.clock.clone())
680            };
681            let started_ms = clock.monotonic_ms();
682            let input = if operation == "put" {
683                let key = input.get("key").and_then(Value::as_str);
684                match args.get(2) {
685                    Some(value) => {
686                        match crate::llm::vm_value_to_json_strict(value, "composition state value")
687                        {
688                            Ok(value) => serde_json::json!({"key": key, "value": value}),
689                            Err(message) => {
690                                let error =
691                                    CompositionStateError::non_json(&operation, key, message);
692                                let outcome = CompositionDispatchOutcome::state_error(&error);
693                                runtime.state.lock().push_result(
694                                    &call,
695                                    &outcome,
696                                    elapsed_ms(&*clock, started_ms),
697                                );
698                                return Err(error.into_vm_error());
699                            }
700                        }
701                    }
702                    None => input,
703                }
704            } else {
705                input
706            };
707            let outcome = dispatch_binding_with_policy(&runtime, &binding, input).await?;
708            runtime
709                .state
710                .lock()
711                .push_result(&call, &outcome, elapsed_ms(&*clock, started_ms));
712            if let Some(error) = outcome.output.error {
713                if let Some(details) = outcome.error_details {
714                    return Err(VmError::Thrown(crate::json_to_vm_value(&details)));
715                }
716                return Err(VmError::Runtime(error));
717            }
718            Ok(crate::json_to_vm_value(
719                &outcome.output.value.unwrap_or(Value::Null),
720            ))
721        }
722    });
723}
724
725async fn dispatch_binding_with_policy(
726    runtime: &CompositionRuntime,
727    binding: &BindingManifestEntry,
728    input: Value,
729) -> Result<CompositionDispatchOutcome, VmError> {
730    if binding.source == state::COMPOSITION_STATE_CAPABILITY {
731        let operation = binding
732            .metadata
733            .get("state_operation")
734            .and_then(Value::as_str)
735            .unwrap_or_default();
736        let key = input.get("key").and_then(Value::as_str);
737        let value = input.get("value").cloned();
738        let result = match (runtime.state_binding.as_ref(), runtime.state_scope.as_ref()) {
739            (Some(limits), Some(scope)) => state::execute(scope, limits, operation, key, value),
740            (Some(_), None) => Err(CompositionStateError::session_required(operation)),
741            (None, _) => Err(CompositionStateError::invalid_key(
742                operation,
743                "composition state binding is unavailable",
744            )),
745        };
746        return Ok(match result {
747            Ok(value) => CompositionDispatchOutcome::state_ok(value),
748            Err(error) => CompositionDispatchOutcome::state_error(&error),
749        });
750    }
751
752    let policy = runtime.state.lock().request.mcp_policy.clone();
753    let retry = policy.retry.clone();
754    let max_attempts = retry.max_attempts.max(1);
755    let can_retry = retry_allowed(binding, &input, &policy);
756    let mut attempt = 1u32;
757    let mut retry_errors = Vec::new();
758    let mut retry_delays_ms = Vec::new();
759
760    loop {
761        let (_global_permit, _server_permit) = runtime.bulkheads.acquire(binding).await?;
762        let call = runtime.host.call(binding, input.clone());
763        let mut output = if let Some(timeout_ms) = policy.call_timeout_ms.filter(|ms| *ms > 0) {
764            match tokio::time::timeout(Duration::from_millis(timeout_ms), call).await {
765                Ok(output) => output,
766                Err(_) => CompositionToolOutput::error(
767                    format!(
768                        "composition binding '{}' timed out after {timeout_ms}ms",
769                        binding.name
770                    ),
771                    ToolCallErrorCategory::Timeout,
772                ),
773            }
774        } else {
775            call.await
776        };
777        drop((_global_permit, _server_permit));
778
779        if output.error.is_none() {
780            if let Some(value) = output.value.take() {
781                match validate_binding_output(binding, value) {
782                    Ok(value) => output.value = Some(value),
783                    Err(message) => {
784                        output = CompositionToolOutput::error(
785                            message,
786                            ToolCallErrorCategory::SchemaValidation,
787                        );
788                    }
789                }
790            }
791        }
792
793        if output.error.is_none()
794            || attempt >= max_attempts
795            || !can_retry
796            || !is_retryable_child_error(&output)
797        {
798            return Ok(CompositionDispatchOutcome {
799                output,
800                error_details: None,
801                attempt,
802                retry_attempts: attempt.saturating_sub(1),
803                retry_errors,
804                retry_delays_ms,
805            });
806        }
807
808        let error = output
809            .error
810            .clone()
811            .unwrap_or_else(|| "composition child call failed".to_string());
812        let delay_ms = compute_retry_delay_ms(binding, &input, attempt, &retry, &error);
813        retry_errors.push(error);
814        retry_delays_ms.push(delay_ms);
815        if delay_ms > 0 {
816            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
817        }
818        attempt = attempt.saturating_add(1);
819    }
820}
821
822fn validate_binding_output(binding: &BindingManifestEntry, value: Value) -> Result<Value, String> {
823    let Some(schema) = &binding.output_schema else {
824        return Ok(value);
825    };
826    let value_vm = crate::json_to_vm_value(&value);
827    let schema_vm = crate::json_to_vm_value(schema);
828    crate::schema::schema_expect_value(&value_vm, &schema_vm, false)
829        .map(|value| crate::llm::vm_value_to_json(&value))
830        .map_err(|error| {
831            format!(
832                "composition binding '{}' outputSchema validation failed: {error}",
833                binding.name
834            )
835        })
836}
837
838fn retry_allowed(
839    binding: &BindingManifestEntry,
840    input: &Value,
841    policy: &CompositionMcpPolicy,
842) -> bool {
843    if idempotency_key_present(input) {
844        return true;
845    }
846    if binding.source == "mcp_server" {
847        if !mcp_binding_trusted(binding, policy) {
848            return false;
849        }
850        return binding.annotations.destructive_hint != Some(true)
851            && (binding.annotations.read_only_hint == Some(true)
852                || binding.annotations.idempotent_hint == Some(true));
853    }
854    binding.side_effect_level == SideEffectLevel::ReadOnly
855        && binding.annotations.kind.is_read_only()
856}
857
858fn mcp_binding_trusted(binding: &BindingManifestEntry, policy: &CompositionMcpPolicy) -> bool {
859    policy.trust_annotations
860        || mcp_server_name(binding)
861            .as_ref()
862            .is_some_and(|server| policy.trusted_servers.contains(server))
863}
864
865fn mcp_server_name(binding: &BindingManifestEntry) -> Option<String> {
866    binding
867        .metadata
868        .get("_mcp_server")
869        .or_else(|| binding.metadata.get("mcp_server"))
870        .or_else(|| binding.metadata.pointer("/server/name"))
871        .and_then(Value::as_str)
872        .filter(|server| !server.is_empty())
873        .map(ToOwned::to_owned)
874}
875
876fn idempotency_key_present(input: &Value) -> bool {
877    for pointer in [
878        "/idempotency_key",
879        "/idempotencyKey",
880        "/_idempotency_key",
881        "/_meta/idempotencyKey",
882        "/_meta/harn/idempotencyKey",
883    ] {
884        if input.pointer(pointer).is_some_and(|value| match value {
885            Value::String(value) => !value.trim().is_empty(),
886            Value::Null => false,
887            _ => true,
888        }) {
889            return true;
890        }
891    }
892    false
893}
894
895fn is_retryable_child_error(output: &CompositionToolOutput) -> bool {
896    if matches!(
897        output.error_category,
898        Some(
899            ToolCallErrorCategory::Network
900                | ToolCallErrorCategory::Timeout
901                | ToolCallErrorCategory::McpServerError
902                | ToolCallErrorCategory::ResourceBusy
903        )
904    ) {
905        return true;
906    }
907    let Some(error) = &output.error else {
908        return false;
909    };
910    let error = error.to_ascii_lowercase();
911    [
912        "429",
913        "503",
914        "retry-after",
915        "rate limit",
916        "rate-limit",
917        "timeout",
918        "timed out",
919        "transient",
920        "overloaded",
921        "server closed connection",
922        "disconnected",
923        "mcp read error",
924        "mcp write error",
925        "connection reset",
926    ]
927    .iter()
928    .any(|needle| error.contains(needle))
929}
930
931fn compute_retry_delay_ms(
932    binding: &BindingManifestEntry,
933    input: &Value,
934    attempt: u32,
935    retry: &CompositionRetryPolicy,
936    error: &str,
937) -> u64 {
938    if retry.max_delay_ms == 0 {
939        return 0;
940    }
941    if retry.honor_retry_after {
942        if let Some(delay) = retry_after_ms_from_error(error) {
943            return delay.min(retry.max_delay_ms);
944        }
945    }
946    let shift = attempt.saturating_sub(1).min(20);
947    let multiplier = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
948    let base = retry.base_delay_ms.saturating_mul(multiplier);
949    if base == 0 {
950        return 0;
951    }
952    let jitter_span = (base / 2).max(1);
953    let mut hasher = Sha256::new();
954    hasher.update(binding.name.as_bytes());
955    hasher.update(b"\0");
956    hasher.update(attempt.to_le_bytes());
957    hasher.update(b"\0");
958    if let Ok(bytes) = serde_json::to_vec(input) {
959        hasher.update(bytes);
960    }
961    let digest = hasher.finalize();
962    let jitter = u64::from_le_bytes(digest[..8].try_into().unwrap_or([0; 8])) % (jitter_span + 1);
963    base.saturating_add(jitter).min(retry.max_delay_ms)
964}
965
966fn retry_after_ms_from_error(error: &str) -> Option<u64> {
967    let lower = error.to_ascii_lowercase();
968    let (_, tail) = lower.split_once("retry-after")?;
969    let value = tail
970        .trim_start_matches(|c: char| c == ':' || c == '=' || c.is_whitespace())
971        .split(|c: char| !c.is_ascii_digit())
972        .next()
973        .filter(|value| !value.is_empty())?;
974    value
975        .parse::<u64>()
976        .ok()
977        .map(|seconds| seconds.saturating_mul(1000))
978}
979
980fn register_composition_map_bounded_builtin(vm: &mut Vm, runtime: CompositionRuntime) {
981    vm.register_async_builtin("map_bounded", move |ctx, args| {
982        let runtime = runtime.clone();
983        async move {
984            let items = match args.first() {
985                Some(VmValue::List(items)) => items.as_ref().clone(),
986                Some(other) => {
987                    return Err(VmError::TypeError(format!(
988                        "map_bounded: first argument must be a list, got {}",
989                        other.type_name()
990                    )))
991                }
992                None => {
993                    return Err(VmError::Runtime(
994                        "map_bounded: first argument must be a list".to_string(),
995                    ))
996                }
997            };
998            let closure = match args.get(1) {
999                Some(VmValue::Closure(closure)) => closure.clone(),
1000                Some(other) => {
1001                    return Err(VmError::TypeError(format!(
1002                        "map_bounded: second argument must be a closure, got {}",
1003                        other.type_name()
1004                    )))
1005                }
1006                None => {
1007                    return Err(VmError::Runtime(
1008                        "map_bounded: second argument must be a closure".to_string(),
1009                    ))
1010                }
1011            };
1012            let options = args
1013                .get(2)
1014                .map(crate::llm::vm_value_to_json)
1015                .unwrap_or_else(|| serde_json::json!({}));
1016            let default_cap = runtime
1017                .state
1018                .lock()
1019                .request
1020                .limits
1021                .max_concurrent_operations
1022                .max(1);
1023            let cap = options
1024                .get("concurrency")
1025                .or_else(|| options.get("max_concurrent"))
1026                .and_then(Value::as_u64)
1027                .map(|value| value.max(1) as usize)
1028                .unwrap_or(default_cap)
1029                .min(items.len().max(1));
1030
1031            let total = items.len();
1032            let mut pending = items.into_iter().enumerate();
1033            let mut in_flight = FuturesUnordered::new();
1034            let mut results: Vec<Option<VmValue>> = vec![None; total];
1035            let mut succeeded = 0i64;
1036            let mut failed = 0i64;
1037
1038            while in_flight.len() < cap {
1039                let Some((index, item)) = pending.next() else {
1040                    break;
1041                };
1042                in_flight.push(run_map_bounded_item(
1043                    ctx.clone(),
1044                    closure.clone(),
1045                    index,
1046                    item,
1047                ));
1048            }
1049            while let Some((index, output, result)) = in_flight.next().await {
1050                ctx.forward_output(&output);
1051                match result {
1052                    Ok(value) => {
1053                        succeeded += 1;
1054                        results[index] = Some(VmValue::enum_variant("Result", "Ok", vec![value]));
1055                    }
1056                    Err(error) => {
1057                        failed += 1;
1058                        results[index] = Some(VmValue::enum_variant(
1059                            "Result",
1060                            "Err",
1061                            vec![VmValue::String(arcstr::ArcStr::from(error.to_string()))],
1062                        ));
1063                    }
1064                }
1065                if let Some((next_index, next_item)) = pending.next() {
1066                    in_flight.push(run_map_bounded_item(
1067                        ctx.clone(),
1068                        closure.clone(),
1069                        next_index,
1070                        next_item,
1071                    ));
1072                }
1073            }
1074
1075            let mut dict = BTreeMap::new();
1076            dict.insert(
1077                "results".to_string(),
1078                VmValue::List(std::sync::Arc::new(
1079                    results
1080                        .into_iter()
1081                        .map(|value| {
1082                            value.unwrap_or_else(|| {
1083                                VmValue::enum_variant(
1084                                    "Result",
1085                                    "Err",
1086                                    vec![VmValue::String(arcstr::ArcStr::from(
1087                                        "map_bounded: task did not produce a result",
1088                                    ))],
1089                                )
1090                            })
1091                        })
1092                        .collect(),
1093                )),
1094            );
1095            dict.insert("succeeded".to_string(), VmValue::Int(succeeded));
1096            dict.insert("failed".to_string(), VmValue::Int(failed));
1097            Ok(VmValue::dict(dict))
1098        }
1099    });
1100}
1101
1102async fn run_map_bounded_item(
1103    ctx: crate::vm::AsyncBuiltinCtx,
1104    closure: std::sync::Arc<crate::VmClosure>,
1105    index: usize,
1106    item: VmValue,
1107) -> (usize, String, Result<VmValue, VmError>) {
1108    let mut vm = ctx.child_vm();
1109    let result = vm.call_closure_pub(&closure, &[item]).await;
1110    let output = vm.take_output();
1111    (index, output, result)
1112}
1113
1114fn elapsed_ms(clock: &dyn harn_clock::Clock, started_ms: i64) -> u64 {
1115    clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64
1116}
1117
1118fn composition_validation_source(snippet: &str) -> String {
1119    let mut source = String::from("pipeline main(harness: Harness) {\n");
1120    source.push_str(snippet);
1121    if !snippet.ends_with('\n') {
1122        source.push('\n');
1123    }
1124    source.push_str("}\n");
1125    source
1126}
1127
1128fn composition_source(manifest: &BindingManifest, snippet: &str) -> String {
1129    let mut source = String::new();
1130    for binding in &manifest.bindings {
1131        source.push_str(&format!(
1132            "fn {}(args = {{}}) {{ return __composition_call(\"{}\", args) }}\n",
1133            binding.binding,
1134            escape_harn_string(&binding.name)
1135        ));
1136    }
1137    source.push_str("pipeline main(harness: Harness) {\n");
1138    if manifest.state.is_some() {
1139        source.push_str(state::harn_runtime_source());
1140    }
1141    source.push_str(snippet);
1142    if !snippet.ends_with('\n') {
1143        source.push('\n');
1144    }
1145    source.push_str("}\n");
1146    source
1147}
1148
1149fn escape_harn_string(value: &str) -> String {
1150    harn_lexer::escape_string_literal(value)
1151}
1152
1153fn validate_composition_program(
1154    program: &[harn_parser::SNode],
1155    manifest: &BindingManifest,
1156) -> Result<(), String> {
1157    use harn_parser::visit::walk_program;
1158    use harn_parser::Node;
1159
1160    let bindings = manifest
1161        .bindings
1162        .iter()
1163        .map(|entry| entry.binding.clone())
1164        .collect::<BTreeSet<_>>();
1165    let state_operations = manifest
1166        .state
1167        .as_ref()
1168        .map(|_| state::operation_names())
1169        .unwrap_or_default();
1170    let mut local_functions = BTreeSet::from(["__composition_call".to_string()]);
1171    walk_program(program, &mut |node| {
1172        if let Node::FnDecl { name, .. } = &node.node {
1173            local_functions.insert(name.clone());
1174        }
1175    });
1176
1177    let mut error = None;
1178    walk_program(program, &mut |node| {
1179        if error.is_some() {
1180            return;
1181        }
1182        match &node.node {
1183            Node::ImportDecl { .. }
1184            | Node::SelectiveImport { .. }
1185            | Node::NamespaceImport { .. } => {
1186                error = Some("composition snippets cannot import modules".to_string());
1187            }
1188            Node::SpawnExpr { .. } | Node::Parallel { .. } => {
1189                error = Some("composition snippets cannot spawn or parallelize work".to_string());
1190            }
1191            Node::HitlExpr { .. } => {
1192                error = Some("composition snippets cannot request HITL directly".to_string());
1193            }
1194            Node::CostRoute { .. } => {
1195                error = Some("composition snippets cannot open LLM routing blocks".to_string());
1196            }
1197            Node::FunctionCall { name, .. } => {
1198                if DENIED_COMPOSITION_CALLS.contains(&name.as_str()) && !bindings.contains(name) {
1199                    error = Some(format!("composition snippets cannot call `{name}`"));
1200                } else if !bindings.contains(name)
1201                    && !state_operations.contains(name)
1202                    && !local_functions.contains(name)
1203                    && !PURE_COMPOSITION_CALLS.contains(&name.as_str())
1204                {
1205                    error = Some(format!(
1206                        "composition call target `{name}` is not a manifest binding or pure helper"
1207                    ));
1208                }
1209            }
1210            Node::MethodCall { object, method, .. }
1211            | Node::OptionalMethodCall { object, method, .. }
1212                if matches!(&object.node, Node::Identifier(name) if name == "state") =>
1213            {
1214                let operation = format!("state.{method}");
1215                if !state_operations.contains(&operation) {
1216                    error = Some(if manifest.state.is_some() {
1217                        format!("composition state has no operation `{method}`")
1218                    } else {
1219                        format!(
1220                            "composition operation `{operation}` is unavailable unless \
1221                             manifest.state is granted"
1222                        )
1223                    });
1224                }
1225            }
1226            _ => {}
1227        }
1228    });
1229    error.map_or(Ok(()), Err)
1230}
1231
1232const DENIED_COMPOSITION_CALLS: &[&str] = &[
1233    "append_file",
1234    "append_file_locked",
1235    "ask_user",
1236    "connector_call",
1237    "copy_file",
1238    "delete_file",
1239    "dual_control",
1240    "escalate_to",
1241    "event_log_emit",
1242    "event_log.emit",
1243    "exec",
1244    "host_call",
1245    "host_tool_call",
1246    "http_delete",
1247    "http_download",
1248    "http_get",
1249    "http_patch",
1250    "http_post",
1251    "http_put",
1252    "http_request",
1253    "llm_call",
1254    "mcp_call",
1255    "mcp_connect",
1256    "pg_execute",
1257    "pg_query",
1258    "request_approval",
1259    "secret_get",
1260    "write_file",
1261    "write_file_bytes",
1262    "replace_file",
1263    "replace_file_result",
1264    "replace_file_bytes",
1265    "replace_file_bytes_result",
1266];
1267
1268const PURE_COMPOSITION_CALLS: &[&str] = &[
1269    "Ok",
1270    "Err",
1271    "abs",
1272    "assert",
1273    "assert_eq",
1274    "assert_ne",
1275    "base64_decode",
1276    "base64_encode",
1277    "ceil",
1278    "contains",
1279    "dedup_by",
1280    "dirname",
1281    "entries",
1282    "ends_with",
1283    "flat_map",
1284    "floor",
1285    "format",
1286    "group_by",
1287    "hash_value",
1288    "hex_decode",
1289    "hex_encode",
1290    "is_err",
1291    "is_ok",
1292    "join",
1293    "jq",
1294    "jq_first",
1295    "json_extract",
1296    "json_parse",
1297    "json_pointer",
1298    "json_stringify",
1299    "keys",
1300    "len",
1301    "lower",
1302    "map_bounded",
1303    "parse_float_or",
1304    "parse_int_or",
1305    "split",
1306    "starts_with",
1307    "to_float",
1308    "to_int",
1309    "to_string",
1310    "trim",
1311    "upper",
1312    "values",
1313];
1314
1315pub fn composition_search_examples(query: &str, limit: usize) -> Value {
1316    let mut examples = vec![
1317        serde_json::json!({
1318            "id": "read-summarize",
1319            "title": "Read two files and return a compact summary",
1320            "language": "harn",
1321            "snippet": "const readme = read_file({path: \"README.md\"})\nconst spec = read_file({path: \"spec/HARN_SPEC.md\", limit: 80})\nreturn {readme: readme, spec_excerpt: spec}",
1322            "required_side_effect_level": "read_only",
1323            "tools": ["read_file"]
1324        }),
1325        serde_json::json!({
1326            "id": "search-then-read",
1327            "title": "Search first, then read the best candidate",
1328            "language": "harn",
1329            "snippet": "const hits = search({query: \"CompositionRunEnvelope\"})\nreturn hits",
1330            "required_side_effect_level": "read_only",
1331            "tools": ["search"]
1332        }),
1333    ];
1334    if !query.trim().is_empty() {
1335        let q = query.to_ascii_lowercase();
1336        examples.retain(|example| {
1337            example
1338                .to_string()
1339                .to_ascii_lowercase()
1340                .contains(q.as_str())
1341        });
1342    }
1343    examples.truncate(limit.max(1));
1344    Value::Array(examples)
1345}