pub mod action;
pub mod promotion;
pub mod types;
pub mod utils;
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use theway_core::Agent;
use theway_core::agent::session::session::Session;
use theway_core::types::{AfterToolCallHook, BeforeToolCallHook, StreamFn};
use super::event::{TriggerEvent, TriggerListener};
use super::notification_hook::{DynNotificationHook, NotificationHookStatus};
use super::runtime::{EvaluationOutcome, TriggerRuntime, TriggerRuntimeConfig};
use super::types::{Trigger, TriggerRecord, TriggerState};
use action::run_trigger_action;
use utils::{build_trigger_prompt_request, cap_control_plane_audit_label};
#[allow(unused_imports)]
pub use types::{
BeforeTriggerActionContext, BeforeTriggerActionHook, BeforeTriggerContext,
BeforeTriggerDecision, BeforeTriggerHook, NotificationStatusSnapshot, OnTriggerPromptHook,
PromoteAction, PromotionCondition, PromotionConditionSkipReason, RunningTriggerState,
TriggerAction, TriggerDelivery, TriggerPromptDecision, TriggerPromptRequest,
};
struct RunningTriggerHandle {
state: RunningTriggerState,
cancel: tokio_util::sync::CancellationToken,
}
struct ResolvedTriggerPrompt {
request: TriggerPromptRequest,
decision: TriggerPromptDecision,
}
pub struct TriggerExecutor {
parent_agent: Arc<Agent>,
parent_session: Session,
runtime: TriggerRuntime,
before_trigger: Option<BeforeTriggerHook>,
on_trigger_prompt: Option<OnTriggerPromptHook>,
before_trigger_action: Option<BeforeTriggerActionHook>,
running_triggers: Arc<Mutex<HashMap<String, RunningTriggerHandle>>>,
notification_hooks: Arc<Mutex<Vec<DynNotificationHook>>>,
listeners: Arc<Mutex<Vec<TriggerListener>>>,
stream_fn: Option<StreamFn>,
before_tool_call: Option<BeforeToolCallHook>,
after_tool_call: Option<AfterToolCallHook>,
active_hook_cancel: Arc<Mutex<Option<tokio_util::sync::CancellationToken>>>,
}
impl TriggerExecutor {
pub fn new(
parent_agent: Arc<Agent>,
parent_session: Session,
runtime: TriggerRuntimeConfig,
before_trigger: Option<BeforeTriggerHook>,
on_trigger_prompt: Option<OnTriggerPromptHook>,
before_trigger_action: Option<BeforeTriggerActionHook>,
stream_fn: Option<StreamFn>,
before_tool_call: Option<BeforeToolCallHook>,
after_tool_call: Option<AfterToolCallHook>,
) -> Self {
Self {
parent_agent,
parent_session,
runtime: TriggerRuntime::with_config(runtime),
before_trigger,
on_trigger_prompt,
before_trigger_action,
running_triggers: Arc::new(Mutex::new(HashMap::new())),
notification_hooks: Arc::new(Mutex::new(Vec::new())),
listeners: Arc::new(Mutex::new(Vec::new())),
stream_fn,
before_tool_call,
after_tool_call,
active_hook_cancel: Arc::new(Mutex::new(None)),
}
}
pub fn subscribe(&self, listener: TriggerListener) -> Box<dyn FnOnce() + Send> {
self.listeners.lock().push(listener);
let listeners = Arc::clone(&self.listeners);
let idx = self.listeners.lock().len() - 1;
Box::new(move || {
listeners.lock().remove(idx);
})
}
pub fn abort(&self) {
if let Some(token) = self.active_hook_cancel.lock().as_ref() {
token.cancel();
}
}
fn emit(&self, event: TriggerEvent) {
let listeners: Vec<TriggerListener> = self.listeners.lock().clone();
for listener in listeners {
let _ =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| listener(event.clone())));
}
}
pub async fn handle_trigger(&self, trigger: Trigger) -> EvaluationOutcome {
self.emit(TriggerEvent::TriggerHandlingStart {
idempotency_key: trigger.idempotency_key.clone(),
source_kind: trigger.source_kind,
source_label: trigger.source_label.clone(),
event_label: trigger.event_label.clone(),
trace_id: trigger.trace_id.clone(),
});
let outcome = self.runtime.evaluate(&trigger);
let (state, evaluator_decision) = match &outcome {
EvaluationOutcome::Accept => {
let permission_decision = self.run_before_trigger_hook(&trigger).await;
match permission_decision {
BeforeTriggerDecision::Allow => (
TriggerState::Accepted,
Some(serde_json::json!({
"outcome": "accept",
"permission": "allow"
})),
),
BeforeTriggerDecision::Deny { reason } => (
TriggerState::PermissionDenied,
Some(serde_json::json!({
"outcome": "accept",
"permission": "deny",
"reason": reason,
})),
),
BeforeTriggerDecision::Prompt { reason } => {
let resolved = self.resolve_trigger_prompt(&trigger, reason).await;
let state = match resolved.decision {
TriggerPromptDecision::Allow => TriggerState::Accepted,
TriggerPromptDecision::Deny { .. }
| TriggerPromptDecision::Timeout { .. } => TriggerState::NeedsApproval,
};
(
state,
Some(serde_json::json!({
"outcome": "accept",
"permission": "prompt",
"trigger_prompt_id": resolved.request.trigger_prompt_id,
"prompt_decision": resolved.decision.as_audit_str(),
"reason": resolved.request.reason,
"decision_reason": resolved.decision.reason(),
})),
)
}
}
}
EvaluationOutcome::Deduped {
replacement_policy,
previous_trace_id,
} => (
TriggerState::Deduped,
Some(serde_json::json!({
"outcome": "deduped",
"replacement_policy": replacement_policy,
"previous_trace_id": previous_trace_id,
})),
),
EvaluationOutcome::CycleSuppressed { hop_count } => (
TriggerState::CycleSuppressed,
Some(serde_json::json!({
"outcome": "cycle_suppressed",
"hop_count": hop_count,
})),
),
};
let mut record = TriggerRecord::received_from(&trigger);
record.state = state;
record.evaluator_decision = evaluator_decision.clone();
let audit_payload = match serde_json::to_value(&record) {
Ok(v) => Some(v),
Err(e) => {
self.emit(TriggerEvent::PersistenceError {
context: "trigger_audit".into(),
message: format!("trigger record serialization failed: {e}"),
});
None
}
};
let audit_entry_id = match audit_payload {
Some(payload) => match self
.parent_session
.append_custom(TriggerRecord::CUSTOM_TYPE, Some(payload))
.await
{
Ok(id) => Some(id),
Err(e) => {
self.emit(TriggerEvent::PersistenceError {
context: "trigger_audit".into(),
message: format!("trigger audit append failed: {:?}", e.code),
});
None
}
},
None => None,
};
let trace_id = trigger.trace_id.clone();
let idempotency_key = trigger.idempotency_key.clone();
self.emit(TriggerEvent::TriggerHandled {
idempotency_key,
trace_id: trace_id.clone(),
state,
audit_entry_id,
evaluator_decision,
});
if state == TriggerState::Accepted {
self.spawn_trigger_action(trigger);
}
outcome
}
fn spawn_trigger_action(&self, trigger: Trigger) {
let trace_id = trigger.trace_id.clone();
let source_label = trigger.source_label.clone();
let event_label = trigger.event_label.clone();
let listeners = Arc::clone(&self.listeners);
let parent_session = self.parent_session.clone();
let parent_agent = Arc::clone(&self.parent_agent);
let running_registry = Arc::clone(&self.running_triggers);
let action_hook = self.before_trigger_action.clone();
let runtime_snapshot = self.runtime.snapshot();
let parent_state = self.parent_agent.state();
let parent_model = parent_state.model.clone();
let parent_system_prompt = parent_state.system_prompt.clone();
let parent_tools = parent_state.tools.clone();
let parent_thinking = parent_state.thinking_level;
let stream_fn = self.stream_fn.clone();
let before_tool_call = self.before_tool_call.clone();
let after_tool_call = self.after_tool_call.clone();
tokio::spawn(async move {
run_trigger_action(
trigger,
trace_id,
source_label,
event_label,
listeners,
parent_session,
parent_agent,
running_registry,
action_hook,
runtime_snapshot,
parent_model,
parent_system_prompt,
parent_tools,
parent_thinking,
stream_fn,
before_tool_call,
after_tool_call,
)
.await;
});
}
async fn run_before_trigger_hook(&self, trigger: &Trigger) -> BeforeTriggerDecision {
let Some(hook) = self.before_trigger.clone() else {
return BeforeTriggerDecision::Allow;
};
let ctx = BeforeTriggerContext {
trigger: trigger.clone(),
runtime: self.runtime.snapshot(),
};
hook(ctx, tokio_util::sync::CancellationToken::new()).await
}
async fn resolve_trigger_prompt(
&self,
trigger: &Trigger,
reason: String,
) -> ResolvedTriggerPrompt {
let request = build_trigger_prompt_request(trigger, reason);
self.emit(TriggerEvent::TriggerPromptRequest {
request: request.clone(),
});
let decision = match self.on_trigger_prompt.clone() {
Some(hook) => {
let cancel = tokio_util::sync::CancellationToken::new();
*self.active_hook_cancel.lock() = Some(cancel.clone());
let decision = hook(request.clone(), cancel).await;
*self.active_hook_cancel.lock() = None;
decision
}
None => TriggerPromptDecision::Deny {
reason: Some(
"trigger prompt required but no on_trigger_prompt hook configured \
(fail-closed deny — see issue #110 design v0.2)"
.to_string(),
),
},
};
self.write_trigger_prompt_audit(&request, &decision).await;
ResolvedTriggerPrompt { request, decision }
}
async fn write_trigger_prompt_audit(
&self,
request: &TriggerPromptRequest,
decision: &TriggerPromptDecision,
) {
let data = serde_json::json!({
"schema_version": 1,
"trigger_prompt_id": request.trigger_prompt_id,
"trace_id": request.trace_id,
"source_label": cap_control_plane_audit_label(&request.source_label),
"receiver_agent_id": request.receiver_agent_id,
"sender_agent_id": request.sender_agent_id,
"action_class": request.action_class,
"decision": decision.as_audit_str(),
"reason": decision.reason(),
"at": chrono::Utc::now().to_rfc3339(),
});
if let Err(e) = self
.parent_session
.append_custom("trigger_prompt", Some(data))
.await
{
self.emit(TriggerEvent::PersistenceError {
context: "trigger_prompt".into(),
message: format!("trigger prompt audit append failed: {:?}", e.code),
});
}
}
pub fn notification_status_snapshot(&self) -> NotificationStatusSnapshot {
let hook_arcs: Vec<DynNotificationHook> = self.notification_hooks.lock().clone();
let hooks: Vec<NotificationHookStatus> = hook_arcs.iter().map(|h| h.status()).collect();
let running: Vec<RunningTriggerState> = self
.running_triggers
.lock()
.values()
.map(|h| h.state.clone())
.collect();
NotificationStatusSnapshot {
hooks,
runtime: self.runtime.snapshot(),
running,
}
}
pub fn abort_trigger(&self, trace_id: &str) {
if let Some(handle) = self.running_triggers.lock().get(trace_id) {
handle.cancel.cancel();
}
}
pub fn abort_all_triggers(&self) {
let cancels: Vec<_> = self
.running_triggers
.lock()
.values()
.map(|h| h.cancel.clone())
.collect();
for c in cancels {
c.cancel();
}
}
pub fn register_notification_hook(self: &Arc<Self>, hook: DynNotificationHook) {
use super::notification_hook::TriggerSink;
let (sink, mut rx): (TriggerSink, _) = tokio::sync::mpsc::unbounded_channel();
self.notification_hooks.lock().push(hook.clone());
let hook_driver = hook.clone();
tokio::spawn(async move {
let _ = hook_driver.run(sink).await;
});
let harness = Arc::clone(self);
tokio::spawn(async move {
while let Some(trigger) = rx.recv().await {
let _ = harness.handle_trigger(trigger).await;
}
});
}
}
#[cfg(test)]
mod coverage_gap {
use super::*;
#[tokio::test]
async fn abort_without_active_prompt_hook_is_a_noop() {
let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
theway_core::AgentHarnessOptions::new(
theway_llm_provider::Model {
id: "faux".into(),
name: "Faux".into(),
api: theway_llm_provider::Api::from("faux"),
provider: theway_llm_provider::Provider::from("faux"),
base_url: String::new(),
reasoning: false,
thinking_level_map: None,
input: vec![],
cost: theway_llm_provider::ModelCost::default(),
context_window: 0,
max_tokens: 0,
headers: None,
compat: None,
},
session.clone(),
),
));
let executor = TriggerExecutor::new(
harness.agent_arc(),
session,
TriggerRuntimeConfig::default(),
None,
None,
None,
None,
None,
None,
);
executor.abort();
assert!(executor.notification_status_snapshot().running.is_empty());
}
#[tokio::test]
async fn emit_isolates_panicking_listener_and_continues_to_others() {
let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
theway_core::AgentHarnessOptions::new(
theway_llm_provider::Model {
id: "faux".into(),
name: "Faux".into(),
api: theway_llm_provider::Api::from("faux"),
provider: theway_llm_provider::Provider::from("faux"),
base_url: String::new(),
reasoning: false,
thinking_level_map: None,
input: vec![],
cost: theway_llm_provider::ModelCost::default(),
context_window: 0,
max_tokens: 0,
headers: None,
compat: None,
},
session.clone(),
),
));
let executor = TriggerExecutor::new(
harness.agent_arc(),
session,
TriggerRuntimeConfig::default(),
None,
None,
None,
None,
None,
None,
);
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let _panic_listener = executor.subscribe(std::sync::Arc::new(move |_| {
panic!("listener panic");
}));
let calls_sink2 = calls.clone();
let _counting_listener = executor.subscribe(std::sync::Arc::new(move |_| {
calls_sink2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
executor.emit(TriggerEvent::TriggerHandlingStart {
idempotency_key: "k".into(),
source_kind: super::super::types::SourceKind::Mcp,
source_label: "src".into(),
event_label: "evt".into(),
trace_id: "trace".into(),
});
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn abort_trigger_unknown_and_all_are_noops_when_no_running_triggers() {
let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
theway_core::AgentHarnessOptions::new(
theway_llm_provider::Model {
id: "faux".into(),
name: "Faux".into(),
api: theway_llm_provider::Api::from("faux"),
provider: theway_llm_provider::Provider::from("faux"),
base_url: String::new(),
reasoning: false,
thinking_level_map: None,
input: vec![],
cost: theway_llm_provider::ModelCost::default(),
context_window: 0,
max_tokens: 0,
headers: None,
compat: None,
},
session.clone(),
),
));
let executor = TriggerExecutor::new(
harness.agent_arc(),
session,
TriggerRuntimeConfig::default(),
None,
None,
None,
None,
None,
None,
);
executor.abort_trigger("no-such-trace");
executor.abort_all_triggers();
assert!(executor.notification_status_snapshot().running.is_empty());
}
}