1use 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
57pub 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
347pub 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 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}