1use super::errors::PackageError;
2use super::*;
3
4pub(crate) fn manifest_capabilities(
5 manifest: &Manifest,
6) -> Option<&harn_vm::llm::capabilities::CapabilitiesFile> {
7 manifest.capabilities.as_ref()
8}
9
10pub(crate) fn is_empty_capabilities(file: &harn_vm::llm::capabilities::CapabilitiesFile) -> bool {
11 file.provider.is_empty() && file.provider_family.is_empty()
12}
13
14pub fn validate_runtime_manifest_extensions(anchor: &Path) -> Result<(), PackageError> {
15 let Some((manifest, _manifest_dir)) = find_nearest_manifest(anchor) else {
16 return Ok(());
17 };
18 validate_handoff_routes(&manifest.handoff_routes, &manifest)
19}
20
21pub fn try_load_runtime_extensions(anchor: &Path) -> Result<RuntimeExtensions, PackageError> {
24 ensure_dependencies_materialized(anchor)?;
25 let Some((root_manifest, manifest_dir)) = find_nearest_manifest(anchor) else {
26 return Ok(RuntimeExtensions::default());
27 };
28
29 let mut llm = harn_vm::llm_config::ProvidersConfig::default();
30 let mut capabilities = harn_vm::llm::capabilities::CapabilitiesFile::default();
31 let mut hooks = Vec::new();
32 let mut triggers = Vec::new();
33
34 llm.merge_from(&root_manifest.llm);
35 if let Some(file) = manifest_capabilities(&root_manifest) {
36 merge_capability_overrides(&mut capabilities, file);
37 }
38 hooks.extend(resolved_hooks_from_manifest(&root_manifest, &manifest_dir));
39 triggers.extend(resolved_triggers_from_manifest(
40 &root_manifest,
41 &manifest_dir,
42 ));
43 let handoff_routes = root_manifest.handoff_routes.clone();
44 validate_handoff_routes(&handoff_routes, &root_manifest)?;
45 let provider_connectors =
46 resolved_provider_connectors_from_manifest(&root_manifest, &manifest_dir);
47
48 Ok(RuntimeExtensions {
49 root_manifest_path: Some(manifest_dir.join(MANIFEST)),
50 root_manifest_dir: Some(manifest_dir),
51 root_manifest: Some(root_manifest),
52 llm: (!llm.is_empty()).then_some(llm),
53 capabilities: (!is_empty_capabilities(&capabilities)).then_some(capabilities),
54 hooks,
55 triggers,
56 handoff_routes,
57 provider_connectors,
58 })
59}
60
61pub fn load_runtime_extensions(anchor: &Path) -> RuntimeExtensions {
62 match try_load_runtime_extensions(anchor) {
63 Ok(extensions) => extensions,
64 Err(error) => {
65 eprintln!("error: {error}");
66 process::exit(1);
67 }
68 }
69}
70
71pub fn install_runtime_extensions(extensions: &RuntimeExtensions) {
73 harn_vm::llm_config::set_user_overrides(extensions.llm.clone());
74 harn_vm::llm::capabilities::set_user_overrides(extensions.capabilities.clone());
75 install_manifest_handoff_routes(extensions);
76 install_orchestrator_budget(extensions);
77}
78
79pub fn install_manifest_handoff_routes(extensions: &RuntimeExtensions) {
80 harn_vm::install_handoff_routes(extensions.handoff_routes.clone());
81}
82
83pub fn install_orchestrator_budget(extensions: &RuntimeExtensions) {
84 let budget = extensions
85 .root_manifest
86 .as_ref()
87 .map(|manifest| harn_vm::OrchestratorBudgetConfig {
88 daily_cost_usd: manifest.orchestrator.budget.daily_cost_usd,
89 hourly_cost_usd: manifest.orchestrator.budget.hourly_cost_usd,
90 })
91 .unwrap_or_default();
92 harn_vm::install_orchestrator_budget(budget);
93}
94
95pub async fn install_manifest_hooks(
96 vm: &mut harn_vm::Vm,
97 extensions: &RuntimeExtensions,
98) -> Result<(), PackageError> {
99 harn_vm::orchestration::clear_runtime_hooks();
100 let mut loaded_exports: HashMap<ManifestModuleCacheKey, ManifestModuleExports> = HashMap::new();
101 for hook in &extensions.hooks {
102 let Some((module_name, function_name)) = hook.handler.rsplit_once("::") else {
103 return Err(format!(
104 "invalid hook handler '{}': expected <module>::<function>",
105 hook.handler
106 )
107 .into());
108 };
109 let cache_key = (
110 hook.manifest_dir.clone(),
111 hook.package_name.clone(),
112 Some(module_name.to_string()),
113 );
114 if !loaded_exports.contains_key(&cache_key) {
115 let exports = resolve_manifest_exports(
116 vm,
117 &hook.manifest_dir,
118 hook.package_name.as_deref(),
119 &hook.exports,
120 Some(module_name),
121 )
122 .await?;
123 loaded_exports.insert(cache_key.clone(), exports);
124 }
125 let exports = loaded_exports
126 .get(&cache_key)
127 .expect("manifest hook exports cached");
128 let Some(closure) = exports.get(function_name) else {
129 return Err(format!(
130 "hook handler '{}' is not exported by module '{}'",
131 function_name, module_name
132 )
133 .into());
134 };
135 harn_vm::orchestration::register_vm_hook(
136 hook.event,
137 hook.pattern.clone(),
138 hook.handler.clone(),
139 closure.clone(),
140 );
141 }
142 Ok(())
143}
144
145pub async fn collect_manifest_triggers(
146 vm: &mut harn_vm::Vm,
147 extensions: &RuntimeExtensions,
148) -> Result<Vec<CollectedManifestTrigger>, PackageError> {
149 let _provider_schema_guard = lock_manifest_provider_schemas().await;
150 let provider_catalog = build_manifest_provider_catalog(extensions).await?;
151 validate_orchestrator_budget(extensions.root_manifest.as_ref())?;
152 validate_static_trigger_configs(&extensions.triggers, &provider_catalog)?;
153 let mut loaded_exports: HashMap<ManifestModuleCacheKey, ManifestModuleExports> = HashMap::new();
154 let mut module_signatures: HashMap<PathBuf, BTreeMap<String, TriggerFunctionSignature>> =
155 HashMap::new();
156 let mut collected = Vec::new();
157
158 for trigger in &extensions.triggers {
159 let handler = parse_trigger_handler_uri(trigger)?;
160 let collected_handler = match handler {
161 TriggerHandlerUri::Local(reference) => {
162 let cache_key = (
163 trigger.manifest_dir.clone(),
164 trigger.package_name.clone(),
165 reference.module_name.clone(),
166 );
167 if !loaded_exports.contains_key(&cache_key) {
168 let exports = resolve_manifest_exports(
169 vm,
170 &trigger.manifest_dir,
171 trigger.package_name.as_deref(),
172 &trigger.exports,
173 reference.module_name.as_deref(),
174 )
175 .await
176 .map_err(|error| trigger_error(trigger, error))?;
177 loaded_exports.insert(cache_key.clone(), exports);
178 }
179 let exports = loaded_exports
180 .get(&cache_key)
181 .expect("manifest trigger exports cached");
182 let Some(closure) = exports.get(&reference.function_name) else {
183 return Err(trigger_error(
184 trigger,
185 format!(
186 "handler '{}' is not exported by the resolved module",
187 reference.raw
188 ),
189 ));
190 };
191 CollectedTriggerHandler::Local {
192 reference,
193 closure: closure.clone(),
194 }
195 }
196 TriggerHandlerUri::A2a {
197 target,
198 allow_cleartext,
199 } => CollectedTriggerHandler::A2a {
200 target,
201 allow_cleartext,
202 },
203 TriggerHandlerUri::Worker { queue } => CollectedTriggerHandler::Worker { queue },
204 TriggerHandlerUri::Persona { name } => {
205 let binding = persona_runtime_binding_for_handler(extensions, trigger, &name)?;
206 CollectedTriggerHandler::Persona { binding }
207 }
208 };
209
210 let collected_when = if let Some(when_raw) = &trigger.when {
211 let reference = parse_local_trigger_ref(when_raw, "when", trigger)?;
212 let cache_key = (
213 trigger.manifest_dir.clone(),
214 trigger.package_name.clone(),
215 reference.module_name.clone(),
216 );
217 if !loaded_exports.contains_key(&cache_key) {
218 let exports = resolve_manifest_exports(
219 vm,
220 &trigger.manifest_dir,
221 trigger.package_name.as_deref(),
222 &trigger.exports,
223 reference.module_name.as_deref(),
224 )
225 .await
226 .map_err(|error| trigger_error(trigger, error))?;
227 loaded_exports.insert(cache_key.clone(), exports);
228 }
229 let exports = loaded_exports
230 .get(&cache_key)
231 .expect("manifest trigger predicate exports cached");
232 let Some(closure) = exports.get(&reference.function_name) else {
233 return Err(trigger_error(
234 trigger,
235 format!(
236 "when predicate '{}' is not exported by the resolved module",
237 reference.raw
238 ),
239 ));
240 };
241
242 let source_path = manifest_module_source_path(
243 &trigger.manifest_dir,
244 trigger.package_name.as_deref(),
245 &trigger.exports,
246 reference.module_name.as_deref(),
247 )
248 .map_err(|error| trigger_error(trigger, error))?;
249 if !module_signatures.contains_key(&source_path) {
250 let signatures = load_trigger_function_signatures(&source_path)
251 .map_err(|error| trigger_error(trigger, error))?;
252 module_signatures.insert(source_path.clone(), signatures);
253 }
254 let signatures = module_signatures
255 .get(&source_path)
256 .expect("module signatures cached");
257 let Some(signature) = signatures.get(&reference.function_name) else {
258 return Err(trigger_error(
259 trigger,
260 format!(
261 "when predicate '{}' must resolve to a function declaration",
262 reference.raw
263 ),
264 ));
265 };
266 if signature.params.len() != 1
267 || signature.params[0]
268 .as_ref()
269 .is_none_or(|param| !is_trigger_event_type(param))
270 {
271 return Err(trigger_error(
272 trigger,
273 format!(
274 "when predicate '{}' must have signature fn(TriggerEvent) -> bool",
275 reference.raw
276 ),
277 ));
278 }
279 if signature
280 .return_type
281 .as_ref()
282 .is_none_or(|return_type| !is_predicate_return_type(return_type))
283 {
284 return Err(trigger_error(
285 trigger,
286 format!(
287 "when predicate '{}' must have signature fn(TriggerEvent) -> bool or Result<bool, _>",
288 reference.raw
289 ),
290 ));
291 }
292
293 Some(CollectedTriggerPredicate {
294 reference,
295 closure: closure.clone(),
296 })
297 } else {
298 None
299 };
300
301 let flow_control = collect_trigger_flow_control(vm, trigger).await?;
302
303 collected.push(CollectedManifestTrigger {
304 config: trigger.clone(),
305 handler: collected_handler,
306 when: collected_when,
307 flow_control,
308 });
309 }
310
311 harn_vm::install_provider_catalog(provider_catalog);
312 Ok(collected)
313}
314
315pub(crate) async fn collect_trigger_flow_control(
316 vm: &mut harn_vm::Vm,
317 trigger: &ResolvedTriggerConfig,
318) -> Result<harn_vm::TriggerFlowControlConfig, PackageError> {
319 let mut flow = harn_vm::TriggerFlowControlConfig::default();
320
321 let concurrency = if let Some(spec) = &trigger.concurrency {
322 Some(spec.clone())
323 } else if let Some(max) = trigger.budget.max_concurrent {
324 eprintln!(
325 "warning: {} uses deprecated budget.max_concurrent; prefer concurrency = {{ max = {} }}",
326 manifest_trigger_location(trigger),
327 max
328 );
329 Some(TriggerConcurrencyManifestSpec { key: None, max })
330 } else {
331 None
332 };
333 if let Some(spec) = concurrency {
334 flow.concurrency = Some(harn_vm::TriggerConcurrencyConfig {
335 key: compile_optional_trigger_expression(
336 vm,
337 trigger,
338 "concurrency.key",
339 spec.key.as_deref(),
340 )
341 .await?,
342 max: spec.max,
343 });
344 }
345
346 if let Some(spec) = &trigger.throttle {
347 flow.throttle = Some(harn_vm::TriggerThrottleConfig {
348 key: compile_optional_trigger_expression(
349 vm,
350 trigger,
351 "throttle.key",
352 spec.key.as_deref(),
353 )
354 .await?,
355 period: harn_vm::parse_flow_control_duration(&spec.period)
356 .map_err(|error| trigger_error(trigger, format!("throttle.period {error}")))?,
357 max: spec.max,
358 });
359 }
360
361 if let Some(spec) = &trigger.rate_limit {
362 flow.rate_limit = Some(harn_vm::TriggerRateLimitConfig {
363 key: compile_optional_trigger_expression(
364 vm,
365 trigger,
366 "rate_limit.key",
367 spec.key.as_deref(),
368 )
369 .await?,
370 period: harn_vm::parse_flow_control_duration(&spec.period)
371 .map_err(|error| trigger_error(trigger, format!("rate_limit.period {error}")))?,
372 max: spec.max,
373 });
374 }
375
376 if let Some(spec) = &trigger.debounce {
377 flow.debounce = Some(harn_vm::TriggerDebounceConfig {
378 key: compile_trigger_expression(vm, trigger, "debounce.key", &spec.key).await?,
379 period: harn_vm::parse_flow_control_duration(&spec.period)
380 .map_err(|error| trigger_error(trigger, format!("debounce.period {error}")))?,
381 });
382 }
383
384 if let Some(spec) = &trigger.singleton {
385 flow.singleton = Some(harn_vm::TriggerSingletonConfig {
386 key: compile_optional_trigger_expression(
387 vm,
388 trigger,
389 "singleton.key",
390 spec.key.as_deref(),
391 )
392 .await?,
393 });
394 }
395
396 if let Some(spec) = &trigger.batch {
397 flow.batch = Some(harn_vm::TriggerBatchConfig {
398 key: compile_optional_trigger_expression(vm, trigger, "batch.key", spec.key.as_deref())
399 .await?,
400 size: spec.size,
401 timeout: harn_vm::parse_flow_control_duration(&spec.timeout)
402 .map_err(|error| trigger_error(trigger, format!("batch.timeout {error}")))?,
403 });
404 }
405
406 if let Some(spec) = &trigger.priority_flow {
407 flow.priority = Some(harn_vm::TriggerPriorityOrderConfig {
408 key: compile_trigger_expression(vm, trigger, "priority.key", &spec.key).await?,
409 order: spec.order.clone(),
410 });
411 }
412
413 Ok(flow)
414}
415
416fn persona_runtime_binding_for_handler(
417 extensions: &RuntimeExtensions,
418 trigger: &ResolvedTriggerConfig,
419 name: &str,
420) -> Result<harn_vm::PersonaRuntimeBinding, PackageError> {
421 let Some(manifest) = extensions.root_manifest.as_ref() else {
422 return Err(trigger_error(
423 trigger,
424 format!("handler persona://{name} requires a root manifest"),
425 ));
426 };
427 let Some(persona) = manifest
428 .personas
429 .iter()
430 .find(|persona| persona.name.as_deref() == Some(name))
431 else {
432 return Err(trigger_error(
433 trigger,
434 format!("handler persona://{name} does not match a declared persona"),
435 ));
436 };
437 Ok(persona_runtime_binding(name, persona))
438}
439
440pub(crate) fn persona_runtime_binding(
444 name: &str,
445 persona: &PersonaManifestEntry,
446) -> harn_vm::PersonaRuntimeBinding {
447 harn_vm::PersonaRuntimeBinding {
448 name: name.to_string(),
449 template_ref: persona_template_ref(persona),
450 entry_workflow: persona.entry_workflow.clone().unwrap_or_default(),
451 schedules: persona.schedules.clone(),
452 triggers: persona.triggers.clone(),
453 budget: harn_vm::PersonaBudgetPolicy {
454 daily_usd: persona.budget.daily_usd,
455 hourly_usd: persona.budget.hourly_usd,
456 run_usd: persona.budget.run_usd,
457 max_tokens: persona.budget.max_tokens,
458 },
459 stages: persona
460 .stages
461 .iter()
462 .map(persona_stage_decl_to_runtime)
463 .collect(),
464 }
465}
466
467fn persona_stage_decl_to_runtime(stage: &PersonaStageDecl) -> harn_vm::StageDecl {
468 harn_vm::StageDecl {
469 name: stage.name.clone(),
470 allowed_tools: stage.allowed_tools.clone(),
471 side_effect_level: stage.side_effect_level.clone(),
472 max_iterations: stage.max_iterations,
473 on_exit: stage.on_exit.as_ref().map(|exit| harn_vm::StageExit {
474 on_complete: exit.on_complete.clone(),
475 on_failure: exit.on_failure.clone(),
476 policy_override: None,
477 }),
478 }
479}
480
481pub(crate) async fn compile_optional_trigger_expression(
482 vm: &mut harn_vm::Vm,
483 trigger: &ResolvedTriggerConfig,
484 field_name: &str,
485 expr: Option<&str>,
486) -> Result<Option<harn_vm::TriggerExpressionSpec>, PackageError> {
487 match expr {
488 Some(expr) => compile_trigger_expression(vm, trigger, field_name, expr)
489 .await
490 .map(Some),
491 None => Ok(None),
492 }
493}
494
495pub(crate) async fn compile_trigger_expression(
496 vm: &mut harn_vm::Vm,
497 trigger: &ResolvedTriggerConfig,
498 field_name: &str,
499 expr: &str,
500) -> Result<harn_vm::TriggerExpressionSpec, PackageError> {
501 let synthetic = PathBuf::from(format!(
502 "<trigger-expr>/{}/{:04}-{}.harn",
503 harn_vm::event_log::sanitize_topic_component(&trigger.id),
504 trigger.table_index,
505 harn_vm::event_log::sanitize_topic_component(field_name),
506 ));
507 let source = format!(
508 "import \"std/triggers\"\n\npub fn __trigger_expr(event: TriggerEvent) -> any {{\n return {expr}\n}}\n"
509 );
510 let exports = vm
511 .load_module_exports_from_source(synthetic, &source)
512 .await
513 .map_err(|error| {
514 trigger_error(
515 trigger,
516 format!("{field_name} '{expr}' is invalid Harn expression: {error}"),
517 )
518 })?;
519 let closure = exports.get("__trigger_expr").ok_or_else(|| {
520 trigger_error(
521 trigger,
522 format!("{field_name} '{expr}' did not compile into an exported closure"),
523 )
524 })?;
525 Ok(harn_vm::TriggerExpressionSpec {
526 raw: expr.to_string(),
527 closure: closure.clone(),
528 })
529}
530
531pub(crate) fn trigger_kind_label(kind: TriggerKind) -> &'static str {
532 match kind {
533 TriggerKind::Webhook => "webhook",
534 TriggerKind::Cron => "cron",
535 TriggerKind::Poll => "poll",
536 TriggerKind::Stream => "stream",
537 TriggerKind::Predicate => "predicate",
538 TriggerKind::A2aPush => "a2a-push",
539 }
540}
541
542pub(crate) fn worker_queue_priority(
543 priority: TriggerDispatchPriority,
544) -> harn_vm::WorkerQueuePriority {
545 match priority {
546 TriggerDispatchPriority::High => harn_vm::WorkerQueuePriority::High,
547 TriggerDispatchPriority::Normal => harn_vm::WorkerQueuePriority::Normal,
548 TriggerDispatchPriority::Low => harn_vm::WorkerQueuePriority::Low,
549 }
550}
551
552pub fn manifest_trigger_binding_spec(
553 trigger: CollectedManifestTrigger,
554) -> harn_vm::TriggerBindingSpec {
555 let flow_control = trigger.flow_control.clone();
556 let config = trigger.config;
557 let (handler, handler_descriptor) = match trigger.handler {
558 CollectedTriggerHandler::Local { reference, closure } => (
559 harn_vm::TriggerHandlerSpec::Local {
560 raw: reference.raw.clone(),
561 closure,
562 },
563 serde_json::json!({
564 "kind": "local",
565 "raw": reference.raw,
566 }),
567 ),
568 CollectedTriggerHandler::A2a {
569 target,
570 allow_cleartext,
571 } => (
572 harn_vm::TriggerHandlerSpec::A2a {
573 target: target.clone(),
574 allow_cleartext,
575 },
576 serde_json::json!({
577 "kind": "a2a",
578 "target": target,
579 "allow_cleartext": allow_cleartext,
580 }),
581 ),
582 CollectedTriggerHandler::Worker { queue } => (
583 harn_vm::TriggerHandlerSpec::Worker {
584 queue: queue.clone(),
585 },
586 serde_json::json!({
587 "kind": "worker",
588 "queue": queue,
589 }),
590 ),
591 CollectedTriggerHandler::Persona { binding } => (
592 harn_vm::TriggerHandlerSpec::Persona {
593 binding: binding.clone(),
594 },
595 serde_json::json!({
596 "kind": "persona",
597 "name": binding.name,
598 "entry_workflow": binding.entry_workflow,
599 }),
600 ),
601 };
602
603 let when_raw = trigger
604 .when
605 .as_ref()
606 .map(|predicate| predicate.reference.raw.clone());
607 let when = trigger.when.map(|predicate| harn_vm::TriggerPredicateSpec {
608 raw: predicate.reference.raw,
609 closure: predicate.closure,
610 });
611 let mut when_budget = config
612 .when_budget
613 .as_ref()
614 .map(|budget| {
615 Ok::<harn_vm::TriggerPredicateBudget, String>(harn_vm::TriggerPredicateBudget {
616 max_cost_usd: budget.max_cost_usd,
617 tokens_max: budget.tokens_max,
618 timeout_ms: budget
619 .timeout
620 .as_deref()
621 .map(parse_duration_millis)
622 .transpose()?,
623 })
624 })
625 .transpose()
626 .unwrap_or_default();
627 if config.budget.max_cost_usd.is_some() || config.budget.max_tokens.is_some() {
628 let budget = when_budget.get_or_insert_with(harn_vm::TriggerPredicateBudget::default);
629 if budget.max_cost_usd.is_none() {
630 budget.max_cost_usd = config.budget.max_cost_usd;
631 }
632 if budget.tokens_max.is_none() {
633 budget.tokens_max = config.budget.max_tokens;
634 }
635 }
636 let id = config.id.clone();
637 let kind = trigger_kind_label(config.kind).to_string();
638 let provider = config.provider.clone();
639 let autonomy_tier = config.autonomy_tier;
640 let match_events = config.match_.events.clone();
641 let dedupe_key = config.dedupe_key.clone();
642 let retry = harn_vm::TriggerRetryConfig::new(
643 config.retry.max,
644 match config.retry.backoff {
645 TriggerRetryBackoff::Immediate => harn_vm::RetryPolicy::Linear { delay_ms: 0 },
646 TriggerRetryBackoff::Svix => harn_vm::RetryPolicy::Svix,
647 },
648 );
649 let filter = config.filter.clone();
650 let dedupe_retention_days = config.retry.retention_days;
651 let daily_cost_usd = config.budget.daily_cost_usd;
652 let hourly_cost_usd = config.budget.hourly_cost_usd;
653 let max_autonomous_decisions_per_hour = config.budget.max_autonomous_decisions_per_hour;
654 let max_autonomous_decisions_per_day = config.budget.max_autonomous_decisions_per_day;
655 let on_budget_exhausted = config.budget.on_budget_exhausted;
656 let max_concurrent = flow_control.concurrency.as_ref().map(|config| config.max);
657 let manifest_path = Some(config.manifest_path.clone());
658 let package_name = config.package_name.clone();
659
660 let fingerprint = serde_json::to_string(&serde_json::json!({
661 "id": &id,
662 "kind": &kind,
663 "provider": provider.as_str(),
664 "autonomy_tier": autonomy_tier,
665 "match": config.match_,
666 "when": when_raw,
667 "when_budget": config.when_budget,
668 "handler": handler_descriptor,
669 "dedupe_key": &dedupe_key,
670 "retry": config.retry,
671 "dispatch_priority": config.dispatch_priority,
672 "budget": config.budget,
673 "flow_control": {
674 "concurrency": config.concurrency,
675 "throttle": config.throttle,
676 "rate_limit": config.rate_limit,
677 "debounce": config.debounce,
678 "singleton": config.singleton,
679 "batch": config.batch,
680 "priority": config.priority_flow,
681 },
682 "window": config.window,
683 "secrets": config.secrets,
684 "filter": &filter,
685 "kind_specific": config.kind_specific,
686 "manifest_path": &manifest_path,
687 "package_name": &package_name,
688 }))
689 .unwrap_or_else(|_| format!("{}:{}:{}", id, kind, provider.as_str()));
690
691 harn_vm::TriggerBindingSpec {
692 id,
693 source: harn_vm::TriggerBindingSource::Manifest,
694 kind,
695 provider,
696 autonomy_tier,
697 handler,
698 dispatch_priority: worker_queue_priority(config.dispatch_priority),
699 when,
700 when_budget,
701 retry,
702 match_events,
703 dedupe_key,
704 filter,
705 dedupe_retention_days,
706 daily_cost_usd,
707 hourly_cost_usd,
708 max_autonomous_decisions_per_hour,
709 max_autonomous_decisions_per_day,
710 on_budget_exhausted,
711 max_concurrent,
712 flow_control,
713 aggregation: None,
714 manifest_path,
715 package_name,
716 definition_fingerprint: fingerprint,
717 }
718}
719
720pub async fn install_manifest_triggers(
721 vm: &mut harn_vm::Vm,
722 extensions: &RuntimeExtensions,
723) -> Result<(), PackageError> {
724 install_orchestrator_budget(extensions);
725 let collected = collect_manifest_triggers(vm, extensions).await?;
726 let mut bindings: Vec<_> = collected
727 .iter()
728 .cloned()
729 .map(manifest_trigger_binding_spec)
730 .collect();
731 bindings.extend(collect_persona_trigger_binding_specs(extensions)?);
732 harn_vm::install_manifest_triggers(bindings)
733 .await
734 .map_err(|error| PackageError::Extensions(error.to_string()))
735}
736
737pub async fn install_collected_manifest_triggers(
738 collected: &[CollectedManifestTrigger],
739) -> Result<(), PackageError> {
740 let bindings = collected
741 .iter()
742 .cloned()
743 .map(manifest_trigger_binding_spec)
744 .collect();
745 harn_vm::install_manifest_triggers(bindings)
746 .await
747 .map_err(|error| PackageError::Extensions(error.to_string()))
748}
749
750pub fn collect_persona_trigger_binding_specs(
751 extensions: &RuntimeExtensions,
752) -> Result<Vec<harn_vm::TriggerBindingSpec>, PackageError> {
753 let Some(manifest) = extensions.root_manifest.clone() else {
754 return Ok(Vec::new());
755 };
756 let manifest_path = extensions
757 .root_manifest_path
758 .clone()
759 .unwrap_or_else(|| PathBuf::from(MANIFEST));
760 let manifest_dir = extensions
761 .root_manifest_dir
762 .clone()
763 .or_else(|| manifest_path.parent().map(Path::to_path_buf))
764 .unwrap_or_else(|| PathBuf::from("."));
765 let resolved = validate_and_resolve_personas(manifest, manifest_path.clone(), manifest_dir)
766 .map_err(|errors| {
767 errors
768 .iter()
769 .map(ToString::to_string)
770 .collect::<Vec<_>>()
771 .join("\n")
772 })?;
773 let mut bindings = Vec::new();
774 for persona in resolved.personas {
775 let Some(name) = persona.name.clone() else {
776 continue;
777 };
778 for trigger in &persona.triggers {
779 let Some((provider, kind)) = trigger.split_once('.') else {
780 continue;
781 };
782 let provider = provider.trim();
783 let kind = kind.trim();
784 if provider.is_empty() || kind.is_empty() {
785 continue;
786 }
787 bindings.push(persona_trigger_binding_spec(
788 &resolved.manifest_path,
789 &name,
790 provider,
791 kind,
792 &persona,
793 ));
794 }
795 }
796 Ok(bindings)
797}
798
799fn persona_trigger_binding_spec(
800 manifest_path: &Path,
801 name: &str,
802 provider: &str,
803 kind: &str,
804 persona: &PersonaManifestEntry,
805) -> harn_vm::TriggerBindingSpec {
806 let runtime_binding = persona_runtime_binding(name, persona);
807 let id = format!("persona.{name}.{provider}.{kind}");
808 let handler = harn_vm::TriggerHandlerSpec::Persona {
809 binding: runtime_binding.clone(),
810 };
811 let fingerprint = serde_json::to_string(&serde_json::json!({
812 "id": &id,
813 "kind": kind,
814 "provider": provider,
815 "handler": {
816 "kind": "persona",
817 "name": name,
818 "entry_workflow": runtime_binding.entry_workflow,
819 },
820 "budget": runtime_binding.budget,
821 "manifest_path": manifest_path,
822 }))
823 .unwrap_or_else(|_| format!("{id}:{provider}:{kind}:{name}"));
824
825 harn_vm::TriggerBindingSpec {
826 id,
827 source: harn_vm::TriggerBindingSource::Manifest,
828 kind: kind.to_string(),
829 provider: harn_vm::ProviderId::from(provider.to_string()),
830 autonomy_tier: persona
831 .autonomy_tier
832 .map(persona_autonomy_to_vm)
833 .unwrap_or(harn_vm::AutonomyTier::Suggest),
834 handler,
835 dispatch_priority: harn_vm::WorkerQueuePriority::Normal,
836 when: None,
837 when_budget: None,
838 retry: harn_vm::TriggerRetryConfig::default(),
839 match_events: vec![kind.to_string()],
840 dedupe_key: None,
841 filter: None,
842 dedupe_retention_days: 7,
843 daily_cost_usd: persona.budget.daily_usd,
844 hourly_cost_usd: persona.budget.hourly_usd,
845 max_autonomous_decisions_per_hour: None,
846 max_autonomous_decisions_per_day: None,
847 on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy::RetryLater,
848 max_concurrent: None,
849 flow_control: harn_vm::TriggerFlowControlConfig::default(),
850 aggregation: None,
851 manifest_path: Some(manifest_path.to_path_buf()),
852 package_name: None,
853 definition_fingerprint: fingerprint,
854 }
855}
856
857fn persona_autonomy_to_vm(value: PersonaAutonomyTier) -> harn_vm::AutonomyTier {
858 match value {
859 PersonaAutonomyTier::Shadow => harn_vm::AutonomyTier::Shadow,
860 PersonaAutonomyTier::Suggest => harn_vm::AutonomyTier::Suggest,
861 PersonaAutonomyTier::ActWithApproval => harn_vm::AutonomyTier::ActWithApproval,
862 PersonaAutonomyTier::ActAuto => harn_vm::AutonomyTier::ActAuto,
863 }
864}
865
866fn persona_template_ref(persona: &PersonaManifestEntry) -> Option<String> {
867 persona
868 .package_source
869 .package
870 .as_ref()
871 .zip(persona.version.as_ref())
872 .map(|(package, version)| format!("{package}@{version}"))
873 .or_else(|| persona.package_source.package.clone())
874 .or_else(|| {
875 persona
876 .name
877 .as_ref()
878 .zip(persona.version.as_ref())
879 .map(|(name, version)| format!("{name}@{version}"))
880 })
881}
882
883pub fn load_personas_from_manifest_path(
884 manifest_path: &Path,
885) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
886 let manifest_path = if manifest_path.is_dir() {
887 manifest_path.join(MANIFEST)
888 } else {
889 manifest_path.to_path_buf()
890 };
891 let manifest_dir = manifest_path
892 .parent()
893 .map(Path::to_path_buf)
894 .unwrap_or_else(|| PathBuf::from("."));
895 if manifest_path.extension().and_then(|ext| ext.to_str()) == Some("harn") {
896 return match harn_modules::personas::parse_persona_source_file(&manifest_path) {
897 Ok(document) if !document.personas.is_empty() => {
898 validate_and_resolve_standalone_personas(
899 document.personas,
900 manifest_path,
901 manifest_dir,
902 )
903 }
904 Ok(_) => Err(vec![PersonaValidationError {
905 manifest_path: manifest_path.clone(),
906 field_path: "persona".to_string(),
907 message: "no @persona declarations found".to_string(),
908 }]),
909 Err(message) => Err(vec![PersonaValidationError {
910 manifest_path: manifest_path.clone(),
911 field_path: "persona".to_string(),
912 message,
913 }]),
914 };
915 }
916 let manifest = match read_manifest_from_path(&manifest_path) {
917 Ok(manifest) => manifest,
918 Err(message) => {
919 if let Ok(document) =
920 harn_modules::personas::parse_persona_manifest_file(&manifest_path)
921 {
922 if !document.personas.is_empty() {
923 return validate_and_resolve_standalone_personas(
924 document.personas,
925 manifest_path,
926 manifest_dir,
927 );
928 }
929 }
930 return Err(vec![PersonaValidationError {
931 manifest_path: manifest_path.clone(),
932 field_path: "harn.toml".to_string(),
933 message: message.to_string(),
934 }]);
935 }
936 };
937 if manifest.personas.is_empty() {
938 if let Ok(document) = harn_modules::personas::parse_persona_manifest_file(&manifest_path) {
939 if !document.personas.is_empty() {
940 return validate_and_resolve_standalone_personas(
941 document.personas,
942 manifest_path,
943 manifest_dir,
944 );
945 }
946 }
947 }
948 validate_and_resolve_personas(manifest, manifest_path, manifest_dir)
949}
950
951fn validate_and_resolve_standalone_personas(
952 personas: Vec<PersonaManifestEntry>,
953 manifest_path: PathBuf,
954 manifest_dir: PathBuf,
955) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
956 let known_names = personas
957 .iter()
958 .filter_map(|persona| persona.name.as_ref())
959 .filter(|name| !name.trim().is_empty())
960 .cloned()
961 .collect();
962 let context = harn_modules::personas::PersonaValidationContext {
963 known_capabilities: harn_modules::personas::default_persona_capabilities(),
964 known_tools: BTreeSet::new(),
965 known_names,
966 };
967 harn_modules::personas::validate_persona_manifests(&manifest_path, &personas, &context)?;
968 Ok(ResolvedPersonaManifest {
969 manifest_path,
970 manifest_dir,
971 personas,
972 })
973}
974
975pub fn load_personas_config(
976 anchor: Option<&Path>,
977) -> Result<Option<ResolvedPersonaManifest>, Vec<PersonaValidationError>> {
978 let anchor = anchor
979 .map(Path::to_path_buf)
980 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
981 let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
982 return Ok(None);
983 };
984 let manifest_path = dir.join(MANIFEST);
985 validate_and_resolve_personas(manifest, manifest_path, dir).map(Some)
986}
987
988pub(crate) fn validate_and_resolve_personas(
989 manifest: Manifest,
990 manifest_path: PathBuf,
991 manifest_dir: PathBuf,
992) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
993 let known_capabilities = known_persona_capabilities(&manifest, &manifest_dir);
994 let known_tools = known_persona_tools(&manifest);
995 let known_names: BTreeSet<String> = manifest
996 .personas
997 .iter()
998 .filter_map(|persona| persona.name.as_ref())
999 .filter(|name| !name.trim().is_empty())
1000 .cloned()
1001 .collect();
1002 let context = harn_modules::personas::PersonaValidationContext {
1003 known_capabilities,
1004 known_tools,
1005 known_names,
1006 };
1007 if let Err(errors) = harn_modules::personas::validate_persona_manifests(
1008 &manifest_path,
1009 &manifest.personas,
1010 &context,
1011 ) {
1012 Err(errors)
1013 } else {
1014 let mut personas = manifest.personas;
1015 attach_entry_workflow_steps(&mut personas, &manifest_dir);
1016 Ok(ResolvedPersonaManifest {
1017 manifest_path,
1018 manifest_dir,
1019 personas,
1020 })
1021 }
1022}
1023
1024fn attach_entry_workflow_steps(personas: &mut [PersonaManifestEntry], manifest_dir: &Path) {
1025 for persona in personas {
1026 if !persona.steps.is_empty() {
1027 continue;
1028 }
1029 let Some(entry_workflow) = persona.entry_workflow.as_deref() else {
1030 continue;
1031 };
1032 let Some((path, entry_name)) = entry_workflow.split_once('#') else {
1033 continue;
1034 };
1035 if !path.ends_with(".harn") {
1036 continue;
1037 }
1038 let source_path = manifest_dir.join(path);
1039 let Ok(document) = harn_modules::personas::parse_persona_source_file(&source_path) else {
1040 continue;
1041 };
1042 let entry_name = entry_name.trim();
1043 if let Some(source_persona) = document.personas.iter().find(|candidate| {
1044 candidate.entry_workflow.as_deref() == Some(entry_name)
1045 || candidate.name.as_deref() == persona.name.as_deref()
1046 }) {
1047 persona.steps.clone_from(&source_persona.steps);
1048 }
1049 }
1050}
1051
1052pub(crate) fn known_persona_capabilities(
1053 manifest: &Manifest,
1054 manifest_dir: &Path,
1055) -> BTreeSet<String> {
1056 let mut capabilities = BTreeSet::new();
1057 for (capability, operations) in default_persona_capability_map() {
1058 for operation in operations {
1059 capabilities.insert(format!("{capability}.{operation}"));
1060 }
1061 }
1062 for (capability, operations) in &manifest.check.host_capabilities {
1063 for operation in operations {
1064 capabilities.insert(format!("{capability}.{operation}"));
1065 }
1066 }
1067 if let Some(path) = manifest.check.host_capabilities_path.as_deref() {
1068 let path = PathBuf::from(path);
1069 let path = if path.is_absolute() {
1070 path
1071 } else {
1072 manifest_dir.join(path)
1073 };
1074 if let Ok(content) = fs::read_to_string(path) {
1075 let parsed_json = serde_json::from_str::<serde_json::Value>(&content).ok();
1076 let parsed_toml = toml::from_str::<toml::Value>(&content)
1077 .ok()
1078 .and_then(|value| serde_json::to_value(value).ok());
1079 if let Some(value) = parsed_json.or(parsed_toml) {
1080 collect_persona_capabilities_from_json(&value, &mut capabilities);
1081 }
1082 }
1083 }
1084 capabilities
1085}
1086
1087pub(crate) fn collect_persona_capabilities_from_json(
1088 value: &serde_json::Value,
1089 out: &mut BTreeSet<String>,
1090) {
1091 let root = value.get("capabilities").unwrap_or(value);
1092 let Some(capabilities) = root.as_object() else {
1093 return;
1094 };
1095 for (capability, entry) in capabilities {
1096 if let Some(list) = entry.as_array() {
1097 for item in list {
1098 if let Some(operation) = item.as_str() {
1099 out.insert(format!("{capability}.{operation}"));
1100 }
1101 }
1102 } else if let Some(obj) = entry.as_object() {
1103 if let Some(list) = obj
1104 .get("operations")
1105 .or_else(|| obj.get("ops"))
1106 .and_then(|v| v.as_array())
1107 {
1108 for item in list {
1109 if let Some(operation) = item.as_str() {
1110 out.insert(format!("{capability}.{operation}"));
1111 }
1112 }
1113 } else {
1114 for (operation, enabled) in obj {
1115 if enabled.as_bool().unwrap_or(true) {
1116 out.insert(format!("{capability}.{operation}"));
1117 }
1118 }
1119 }
1120 }
1121 }
1122}
1123
1124pub(crate) fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
1125 harn_modules::personas::default_persona_capability_map()
1126}
1127
1128pub(crate) fn known_persona_tools(manifest: &Manifest) -> BTreeSet<String> {
1129 let mut tools = BTreeSet::from([
1130 "a2a".to_string(),
1131 "acp".to_string(),
1132 "ci".to_string(),
1133 "filesystem".to_string(),
1134 "github".to_string(),
1135 "linear".to_string(),
1136 "mcp".to_string(),
1137 "notion".to_string(),
1138 "pagerduty".to_string(),
1139 "shell".to_string(),
1140 "slack".to_string(),
1141 ]);
1142 for server in &manifest.mcp {
1143 tools.insert(server.name.clone());
1144 }
1145 for provider in &manifest.providers {
1146 tools.insert(provider.id.as_str().to_string());
1147 }
1148 for trigger in &manifest.triggers {
1149 if let Some(provider) = trigger.provider.as_ref() {
1150 tools.insert(provider.as_str().to_string());
1151 }
1152 for source in &trigger.sources {
1153 tools.insert(source.provider.as_str().to_string());
1154 }
1155 }
1156 tools
1157}
1158
1159#[cfg(test)]
1160#[path = "extensions_tests.rs"]
1161mod tests;