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