1pub mod action;
14pub mod promotion;
15pub mod types;
16pub mod utils;
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use parking_lot::Mutex;
22use theway_core::Agent;
23use theway_core::agent::session::session::Session;
24use theway_core::types::{AfterToolCallHook, BeforeToolCallHook, StreamFn};
25
26use super::event::{TriggerEvent, TriggerListener};
27use super::notification_hook::{DynNotificationHook, NotificationHookStatus};
28use super::runtime::{EvaluationOutcome, TriggerRuntime, TriggerRuntimeConfig};
29use super::types::{Trigger, TriggerRecord, TriggerState};
30use action::run_trigger_action;
31use utils::{build_trigger_prompt_request, cap_control_plane_audit_label};
32
33#[allow(unused_imports)]
37pub use types::{
38 BeforeTriggerActionContext, BeforeTriggerActionHook, BeforeTriggerContext,
39 BeforeTriggerDecision, BeforeTriggerHook, NotificationStatusSnapshot, OnTriggerPromptHook,
40 PromoteAction, PromotionCondition, PromotionConditionSkipReason, RunningTriggerState,
41 TriggerAction, TriggerDelivery, TriggerPromptDecision, TriggerPromptRequest,
42};
43
44struct RunningTriggerHandle {
52 state: RunningTriggerState,
53 cancel: tokio_util::sync::CancellationToken,
54}
55
56struct ResolvedTriggerPrompt {
60 request: TriggerPromptRequest,
61 decision: TriggerPromptDecision,
62}
63
64pub struct TriggerExecutor {
68 parent_agent: Arc<Agent>,
69 parent_session: Session,
70 runtime: TriggerRuntime,
72 before_trigger: Option<BeforeTriggerHook>,
73 on_trigger_prompt: Option<OnTriggerPromptHook>,
74 before_trigger_action: Option<BeforeTriggerActionHook>,
75 running_triggers: Arc<Mutex<HashMap<String, RunningTriggerHandle>>>,
76 notification_hooks: Arc<Mutex<Vec<DynNotificationHook>>>,
77 listeners: Arc<Mutex<Vec<TriggerListener>>>,
78 stream_fn: Option<StreamFn>,
79 before_tool_call: Option<BeforeToolCallHook>,
80 after_tool_call: Option<AfterToolCallHook>,
81 active_hook_cancel: Arc<Mutex<Option<tokio_util::sync::CancellationToken>>>,
82}
83
84impl TriggerExecutor {
85 pub fn new(
86 parent_agent: Arc<Agent>,
87 parent_session: Session,
88 runtime: TriggerRuntimeConfig,
89 before_trigger: Option<BeforeTriggerHook>,
90 on_trigger_prompt: Option<OnTriggerPromptHook>,
91 before_trigger_action: Option<BeforeTriggerActionHook>,
92 stream_fn: Option<StreamFn>,
93 before_tool_call: Option<BeforeToolCallHook>,
94 after_tool_call: Option<AfterToolCallHook>,
95 ) -> Self {
96 Self {
97 parent_agent,
98 parent_session,
99 runtime: TriggerRuntime::with_config(runtime),
100 before_trigger,
101 on_trigger_prompt,
102 before_trigger_action,
103 running_triggers: Arc::new(Mutex::new(HashMap::new())),
104 notification_hooks: Arc::new(Mutex::new(Vec::new())),
105 listeners: Arc::new(Mutex::new(Vec::new())),
106 stream_fn,
107 before_tool_call,
108 after_tool_call,
109 active_hook_cancel: Arc::new(Mutex::new(None)),
110 }
111 }
112
113 pub fn subscribe(&self, listener: TriggerListener) -> Box<dyn FnOnce() + Send> {
115 self.listeners.lock().push(listener);
116 let listeners = Arc::clone(&self.listeners);
117 let idx = self.listeners.lock().len() - 1;
118 Box::new(move || {
119 listeners.lock().remove(idx);
120 })
121 }
122
123 pub fn abort(&self) {
127 if let Some(token) = self.active_hook_cancel.lock().as_ref() {
128 token.cancel();
129 }
130 }
131
132 fn emit(&self, event: TriggerEvent) {
134 let listeners: Vec<TriggerListener> = self.listeners.lock().clone();
135 for listener in listeners {
136 let _ =
137 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| listener(event.clone())));
138 }
139 }
140
141 pub async fn handle_trigger(&self, trigger: Trigger) -> EvaluationOutcome {
142 self.emit(TriggerEvent::TriggerHandlingStart {
143 idempotency_key: trigger.idempotency_key.clone(),
144 source_kind: trigger.source_kind,
145 source_label: trigger.source_label.clone(),
146 event_label: trigger.event_label.clone(),
147 trace_id: trigger.trace_id.clone(),
148 });
149
150 let outcome = self.runtime.evaluate(&trigger);
151
152 let (state, evaluator_decision) = match &outcome {
153 EvaluationOutcome::Accept => {
154 let permission_decision = self.run_before_trigger_hook(&trigger).await;
158 match permission_decision {
159 BeforeTriggerDecision::Allow => (
160 TriggerState::Accepted,
161 Some(serde_json::json!({
162 "outcome": "accept",
163 "permission": "allow"
164 })),
165 ),
166 BeforeTriggerDecision::Deny { reason } => (
167 TriggerState::PermissionDenied,
168 Some(serde_json::json!({
169 "outcome": "accept",
170 "permission": "deny",
171 "reason": reason,
172 })),
173 ),
174 BeforeTriggerDecision::Prompt { reason } => {
175 let resolved = self.resolve_trigger_prompt(&trigger, reason).await;
176 let state = match resolved.decision {
177 TriggerPromptDecision::Allow => TriggerState::Accepted,
178 TriggerPromptDecision::Deny { .. }
179 | TriggerPromptDecision::Timeout { .. } => TriggerState::NeedsApproval,
180 };
181 (
182 state,
183 Some(serde_json::json!({
184 "outcome": "accept",
185 "permission": "prompt",
186 "trigger_prompt_id": resolved.request.trigger_prompt_id,
187 "prompt_decision": resolved.decision.as_audit_str(),
188 "reason": resolved.request.reason,
189 "decision_reason": resolved.decision.reason(),
190 })),
191 )
192 }
193 }
194 }
195 EvaluationOutcome::Deduped {
196 replacement_policy,
197 previous_trace_id,
198 } => (
199 TriggerState::Deduped,
200 Some(serde_json::json!({
201 "outcome": "deduped",
202 "replacement_policy": replacement_policy,
203 "previous_trace_id": previous_trace_id,
204 })),
205 ),
206 EvaluationOutcome::CycleSuppressed { hop_count } => (
207 TriggerState::CycleSuppressed,
208 Some(serde_json::json!({
209 "outcome": "cycle_suppressed",
210 "hop_count": hop_count,
211 })),
212 ),
213 };
214
215 let mut record = TriggerRecord::received_from(&trigger);
216 record.state = state;
217 record.evaluator_decision = evaluator_decision.clone();
218
219 let audit_payload = match serde_json::to_value(&record) {
220 Ok(v) => Some(v),
221 Err(e) => {
222 self.emit(TriggerEvent::PersistenceError {
226 context: "trigger_audit".into(),
227 message: format!("trigger record serialization failed: {e}"),
228 });
229 None
230 }
231 };
232
233 let audit_entry_id = match audit_payload {
234 Some(payload) => match self
235 .parent_session
236 .append_custom(TriggerRecord::CUSTOM_TYPE, Some(payload))
237 .await
238 {
239 Ok(id) => Some(id),
240 Err(e) => {
241 self.emit(TriggerEvent::PersistenceError {
242 context: "trigger_audit".into(),
243 message: format!("trigger audit append failed: {:?}", e.code),
244 });
245 None
246 }
247 },
248 None => None,
249 };
250
251 let trace_id = trigger.trace_id.clone();
252 let idempotency_key = trigger.idempotency_key.clone();
253
254 self.emit(TriggerEvent::TriggerHandled {
255 idempotency_key,
256 trace_id: trace_id.clone(),
257 state,
258 audit_entry_id,
259 evaluator_decision,
260 });
261
262 if state == TriggerState::Accepted {
266 self.spawn_trigger_action(trigger);
267 }
268
269 outcome
270 }
271
272 fn spawn_trigger_action(&self, trigger: Trigger) {
284 let trace_id = trigger.trace_id.clone();
289 let source_label = trigger.source_label.clone();
290 let event_label = trigger.event_label.clone();
291 let listeners = Arc::clone(&self.listeners);
292 let parent_session = self.parent_session.clone();
293 let parent_agent = Arc::clone(&self.parent_agent);
294 let running_registry = Arc::clone(&self.running_triggers);
295 let action_hook = self.before_trigger_action.clone();
296 let runtime_snapshot = self.runtime.snapshot();
297 let parent_state = self.parent_agent.state();
298 let parent_model = parent_state.model.clone();
299 let parent_system_prompt = parent_state.system_prompt.clone();
300 let parent_tools = parent_state.tools.clone();
301 let parent_thinking = parent_state.thinking_level;
302 let stream_fn = self.stream_fn.clone();
303 let before_tool_call = self.before_tool_call.clone();
304 let after_tool_call = self.after_tool_call.clone();
305
306 tokio::spawn(async move {
307 run_trigger_action(
308 trigger,
309 trace_id,
310 source_label,
311 event_label,
312 listeners,
313 parent_session,
314 parent_agent,
315 running_registry,
316 action_hook,
317 runtime_snapshot,
318 parent_model,
319 parent_system_prompt,
320 parent_tools,
321 parent_thinking,
322 stream_fn,
323 before_tool_call,
324 after_tool_call,
325 )
326 .await;
327 });
328 }
329
330 async fn run_before_trigger_hook(&self, trigger: &Trigger) -> BeforeTriggerDecision {
338 let Some(hook) = self.before_trigger.clone() else {
339 return BeforeTriggerDecision::Allow;
340 };
341 let ctx = BeforeTriggerContext {
342 trigger: trigger.clone(),
343 runtime: self.runtime.snapshot(),
344 };
345 hook(ctx, tokio_util::sync::CancellationToken::new()).await
346 }
347
348 async fn resolve_trigger_prompt(
349 &self,
350 trigger: &Trigger,
351 reason: String,
352 ) -> ResolvedTriggerPrompt {
353 let request = build_trigger_prompt_request(trigger, reason);
354
355 self.emit(TriggerEvent::TriggerPromptRequest {
356 request: request.clone(),
357 });
358
359 let decision = match self.on_trigger_prompt.clone() {
360 Some(hook) => {
361 let cancel = tokio_util::sync::CancellationToken::new();
362 *self.active_hook_cancel.lock() = Some(cancel.clone());
363 let decision = hook(request.clone(), cancel).await;
364 *self.active_hook_cancel.lock() = None;
365 decision
366 }
367 None => TriggerPromptDecision::Deny {
368 reason: Some(
369 "trigger prompt required but no on_trigger_prompt hook configured \
370 (fail-closed deny — see issue #110 design v0.2)"
371 .to_string(),
372 ),
373 },
374 };
375
376 self.write_trigger_prompt_audit(&request, &decision).await;
377 ResolvedTriggerPrompt { request, decision }
378 }
379
380 async fn write_trigger_prompt_audit(
381 &self,
382 request: &TriggerPromptRequest,
383 decision: &TriggerPromptDecision,
384 ) {
385 let data = serde_json::json!({
386 "schema_version": 1,
387 "trigger_prompt_id": request.trigger_prompt_id,
388 "trace_id": request.trace_id,
389 "source_label": cap_control_plane_audit_label(&request.source_label),
390 "receiver_agent_id": request.receiver_agent_id,
391 "sender_agent_id": request.sender_agent_id,
392 "action_class": request.action_class,
393 "decision": decision.as_audit_str(),
394 "reason": decision.reason(),
395 "at": chrono::Utc::now().to_rfc3339(),
396 });
397
398 if let Err(e) = self
399 .parent_session
400 .append_custom("trigger_prompt", Some(data))
401 .await
402 {
403 self.emit(TriggerEvent::PersistenceError {
404 context: "trigger_prompt".into(),
405 message: format!("trigger prompt audit append failed: {:?}", e.code),
406 });
407 }
408 }
409
410 pub fn notification_status_snapshot(&self) -> NotificationStatusSnapshot {
418 let hook_arcs: Vec<DynNotificationHook> = self.notification_hooks.lock().clone();
422 let hooks: Vec<NotificationHookStatus> = hook_arcs.iter().map(|h| h.status()).collect();
423 let running: Vec<RunningTriggerState> = self
427 .running_triggers
428 .lock()
429 .values()
430 .map(|h| h.state.clone())
431 .collect();
432 NotificationStatusSnapshot {
433 hooks,
434 runtime: self.runtime.snapshot(),
435 running,
436 }
437 }
438
439 pub fn abort_trigger(&self, trace_id: &str) {
445 if let Some(handle) = self.running_triggers.lock().get(trace_id) {
446 handle.cancel.cancel();
447 }
448 }
449
450 pub fn abort_all_triggers(&self) {
454 let cancels: Vec<_> = self
455 .running_triggers
456 .lock()
457 .values()
458 .map(|h| h.cancel.clone())
459 .collect();
460 for c in cancels {
461 c.cancel();
462 }
463 }
464
465 pub fn register_notification_hook(self: &Arc<Self>, hook: DynNotificationHook) {
485 use super::notification_hook::TriggerSink;
486 let (sink, mut rx): (TriggerSink, _) = tokio::sync::mpsc::unbounded_channel();
487
488 self.notification_hooks.lock().push(hook.clone());
491
492 let hook_driver = hook.clone();
497 tokio::spawn(async move {
498 let _ = hook_driver.run(sink).await;
499 });
500
501 let harness = Arc::clone(self);
514 tokio::spawn(async move {
515 while let Some(trigger) = rx.recv().await {
516 let _ = harness.handle_trigger(trigger).await;
517 }
518 });
519 }
520}
521
522#[cfg(test)]
523mod coverage_gap {
524 use super::*;
525
526 #[tokio::test]
527 async fn abort_without_active_prompt_hook_is_a_noop() {
528 let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
529 let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
530 let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
531 theway_core::AgentHarnessOptions::new(
532 theway_llm_provider::Model {
533 id: "faux".into(),
534 name: "Faux".into(),
535 api: theway_llm_provider::Api::from("faux"),
536 provider: theway_llm_provider::Provider::from("faux"),
537 base_url: String::new(),
538 reasoning: false,
539 thinking_level_map: None,
540 input: vec![],
541 cost: theway_llm_provider::ModelCost::default(),
542 context_window: 0,
543 max_tokens: 0,
544 headers: None,
545 compat: None,
546 },
547 session.clone(),
548 ),
549 ));
550 let executor = TriggerExecutor::new(
551 harness.agent_arc(),
552 session,
553 TriggerRuntimeConfig::default(),
554 None,
555 None,
556 None,
557 None,
558 None,
559 None,
560 );
561
562 executor.abort();
563 assert!(executor.notification_status_snapshot().running.is_empty());
564 }
565
566 #[tokio::test]
567 async fn emit_isolates_panicking_listener_and_continues_to_others() {
568 let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
569 let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
570 let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
571 theway_core::AgentHarnessOptions::new(
572 theway_llm_provider::Model {
573 id: "faux".into(),
574 name: "Faux".into(),
575 api: theway_llm_provider::Api::from("faux"),
576 provider: theway_llm_provider::Provider::from("faux"),
577 base_url: String::new(),
578 reasoning: false,
579 thinking_level_map: None,
580 input: vec![],
581 cost: theway_llm_provider::ModelCost::default(),
582 context_window: 0,
583 max_tokens: 0,
584 headers: None,
585 compat: None,
586 },
587 session.clone(),
588 ),
589 ));
590 let executor = TriggerExecutor::new(
591 harness.agent_arc(),
592 session,
593 TriggerRuntimeConfig::default(),
594 None,
595 None,
596 None,
597 None,
598 None,
599 None,
600 );
601
602 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
603 let _panic_listener = executor.subscribe(std::sync::Arc::new(move |_| {
604 panic!("listener panic");
605 }));
606 let calls_sink2 = calls.clone();
607 let _counting_listener = executor.subscribe(std::sync::Arc::new(move |_| {
608 calls_sink2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
609 }));
610
611 executor.emit(TriggerEvent::TriggerHandlingStart {
612 idempotency_key: "k".into(),
613 source_kind: super::super::types::SourceKind::Mcp,
614 source_label: "src".into(),
615 event_label: "evt".into(),
616 trace_id: "trace".into(),
617 });
618
619 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
620 }
621
622 #[tokio::test]
623 async fn abort_trigger_unknown_and_all_are_noops_when_no_running_triggers() {
624 let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
625 let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
626 let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
627 theway_core::AgentHarnessOptions::new(
628 theway_llm_provider::Model {
629 id: "faux".into(),
630 name: "Faux".into(),
631 api: theway_llm_provider::Api::from("faux"),
632 provider: theway_llm_provider::Provider::from("faux"),
633 base_url: String::new(),
634 reasoning: false,
635 thinking_level_map: None,
636 input: vec![],
637 cost: theway_llm_provider::ModelCost::default(),
638 context_window: 0,
639 max_tokens: 0,
640 headers: None,
641 compat: None,
642 },
643 session.clone(),
644 ),
645 ));
646 let executor = TriggerExecutor::new(
647 harness.agent_arc(),
648 session,
649 TriggerRuntimeConfig::default(),
650 None,
651 None,
652 None,
653 None,
654 None,
655 None,
656 );
657
658 executor.abort_trigger("no-such-trace");
659 executor.abort_all_triggers();
660 assert!(executor.notification_status_snapshot().running.is_empty());
661 }
662}