Skip to main content

theway_daemon/trigger_engine/
event.rs

1//! Trigger lifecycle events emitted by the [`TriggerExecutor`](super::execution::TriggerExecutor)
2//! (moved out of `theway_core::SessionEvent`). The CLI subscribes to these alongside the
3//! core harness event stream: the executor is host-owned, so its event surface is a
4//! host-level contract — the TUI banner, `/triggers` command and JSONL listeners all
5//! consume this stream.
6//!
7//! Causality notes (RFC 1 §5.F, pinned by tests):
8//! - `TriggerHandled { state: Accepted }` always precedes `TriggerExecutionStarted` for
9//!   the same `trace_id`.
10//! - `TriggerCompleted | TriggerFailed` → `TriggerPromoted` for the same `trace_id` when
11//!   promotion is configured AND not held for approval.
12
13use std::sync::Arc;
14
15use super::types::{SourceKind, TriggerState};
16use crate::trigger_engine::execution::types::TriggerPromptRequest;
17
18/// Listener for [`TriggerEvent`]. Same shape as `SessionListener` so UI adapters can mix
19/// both streams with the same closure style.
20pub type TriggerListener = Arc<dyn Fn(TriggerEvent) + Send + Sync>;
21
22/// Event emitted by the trigger executor as a trigger moves through its lifecycle.
23#[derive(Clone, Debug)]
24pub enum TriggerEvent {
25    /// The executor has admitted a `Trigger` for processing — fires immediately at the
26    /// start of `TriggerExecutor::handle_trigger` before evaluation. Carries the source
27    /// identification needed to render a "processing X" banner. RFC 1 §2.7.
28    TriggerHandlingStart {
29        idempotency_key: String,
30        source_kind: SourceKind,
31        source_label: String,
32        event_label: String,
33        trace_id: String,
34    },
35    /// Terminal: the trigger reached an end state. `state` is one of the terminal variants
36    /// (`Accepted` / `Deduped` / `CycleSuppressed` / `PermissionDenied` / `NeedsApproval`).
37    ///
38    /// `audit_entry_id` is the `SessionTreeEntry::Custom` id when persistence succeeded,
39    /// `None` if persistence failed (a parallel `PersistenceError` event will describe
40    /// the failure).
41    ///
42    /// `evaluator_decision` mirrors what was persisted in the audit record (same JSON
43    /// shape) so live subscribers (TUI banner, `/triggers`, JSONL logs) can render *why*
44    /// the trigger reached its state without a secondary session lookup. Shape:
45    /// - Accept (Allow): `{ "outcome": "accept", "permission": "allow" }`
46    /// - Accept (Deny):  `{ "outcome": "accept", "permission": "deny",   "reason": ... }`
47    /// - Accept (Prompt):`{ "outcome": "accept", "permission": "prompt", "reason": ... }`
48    /// - Deduped:        `{ "outcome": "deduped", "replacement_policy": ..., "previous_trace_id": ... }`
49    /// - CycleSuppressed:`{ "outcome": "cycle_suppressed", "hop_count": N }`
50    ///
51    /// `None` only when audit serialization failed (a `PersistenceError` will accompany).
52    TriggerHandled {
53        idempotency_key: String,
54        trace_id: String,
55        state: TriggerState,
56        audit_entry_id: Option<String>,
57        evaluator_decision: Option<serde_json::Value>,
58    },
59    /// A trigger admitted by the dedup / cycle evaluator reached
60    /// `BeforeTriggerDecision::Prompt` and is awaiting an embedder-owned user decision.
61    ///
62    /// The prompt is bound by `trigger_prompt_id`, not by a tool-call id / args hash, so a
63    /// decision cannot be replayed onto a different trigger envelope. The executor also
64    /// writes a `trigger_prompt` Custom audit entry when the prompt resolves.
65    TriggerPromptRequest { request: TriggerPromptRequest },
66    /// Best-effort persistence error reflux from the trigger engine. The trigger itself
67    /// still produced a `TriggerHandled` event with `audit_entry_id = None`; this event
68    /// explains why so that observability (TUI banner, `/triggers`, JSONL logs) can mark
69    /// the audit as best-effort lost rather than dropping it silently.
70    ///
71    /// `context` is free-form with pinned strings: `"trigger_audit"`, `"trigger_result"`,
72    /// `"trigger_prompt"`, `"trigger_promotion"`, `"trigger_inject_and_run"`. New write
73    /// sites must pin themselves to a stable string.
74    PersistenceError {
75        context: String,
76        /// Short, secret-free message. The original `SessionError` is *not* exposed because
77        /// some implementations include filesystem paths or storage backend details that
78        /// belong in trace logs, not user-facing event surfaces.
79        message: String,
80    },
81    /// A sub-agent execution started for an accepted trigger. Emitted by the spawned task
82    /// just before the sub-agent's first turn runs. `prompt_preview` is the first ~80
83    /// characters of the resolved action prompt, preview-safe for banners.
84    TriggerExecutionStarted {
85        trace_id: String,
86        source_label: String,
87        event_label: String,
88        prompt_preview: String,
89    },
90    /// A sub-agent execution finished successfully and the parent `trigger_result` audit
91    /// entry has been written. `summary` is the sub-agent's self-summary (size-capped at
92    /// 4 KiB). `cost_usd` is `None` when the bare sub-`Agent` had no `CostTracker` wrapper
93    /// (mirrors the audit's `cost_usd: null`).
94    ///
95    /// `details` is the structured sub-agent result envelope populated through marker tools
96    /// (see `TriggerResultDetailsBuilder`). Defaults to `serde_json::Value::Null`.
97    /// Authorization for `PromoteAction::PromoteSummaryWhenResultDetailsMatch` flows
98    /// exclusively through this field — `summary` is display-only.
99    TriggerCompleted {
100        trace_id: String,
101        summary: Option<String>,
102        cost_usd: Option<f64>,
103        details: serde_json::Value,
104    },
105    /// A sub-agent execution failed (agent loop error, panic-via-spawn-error, or aborted by
106    /// `TriggerExecutor::abort_trigger` / `abort_all_triggers`). `reason` is sanitized —
107    /// never contains raw payload, provider response bodies, or credential material. The
108    /// parent `trigger_result` audit entry has been written with `success: false`.
109    TriggerFailed { trace_id: String, reason: String },
110    /// An `TriggerDelivery::InjectAndRun` trigger has injected its prompt into the **idle**
111    /// parent conversation and is asking the embedder to run ONE model turn in the parent's
112    /// full context. The executor never runs the single-tenant parent agent itself from the
113    /// detached trigger task, so it delegates: the embedder (which owns the parent agent
114    /// and its input loop) should funnel this through the same serialized path as user
115    /// input and call `AgentHarness::continue_`. Emitted only on the idle path — when the
116    /// parent is mid-turn the runtime enqueues a follow-up instead and this event is NOT
117    /// emitted.
118    TriggerRequestsMainRun { trace_id: String },
119    /// A trigger's `PromoteAction` rendered successfully and the executor committed to
120    /// surfacing the sub-agent result to the user / LLM. theway_llm_provider has no System
121    /// role today; the inserted entry is a `Message::User` with a `[Trigger ...]` body
122    /// prefix so the LLM disambiguates trigger-driven context from human input.
123    ///
124    /// `inserted_entry_id` semantics depend on the parent agent state at promotion time:
125    /// - **Idle parent**: durable id of the appended `Message::User` (matches the audit).
126    /// - **Streaming parent** (queued through the loop's follow-up queue): **empty string**
127    ///   because the session entry ID is only known after the loop drains the queue.
128    ///   Consumers should correlate by `trace_id` in this case.
129    TriggerPromoted {
130        trace_id: String,
131        promote_kind: String,
132        inserted_entry_id: String,
133        template_name: Option<String>,
134        redaction_status: String,
135    },
136    /// A trigger's `PromoteAction` was held pending approval (`promote_requires_approval =
137    /// true`) and is awaiting an explicit `/triggers approve <trace_id>`. The parent
138    /// transcript has NOT been modified; a `trigger_promotion` audit entry with
139    /// `state: "pending"` has been written. `preview` is the rendered template body the
140    /// approval UI would surface, or `None` when the render itself would have failed.
141    PromotionPending {
142        trace_id: String,
143        promote_kind: String,
144        template_name: Option<String>,
145        preview: Option<String>,
146    },
147}