Skip to main content

theway_daemon/trigger_engine/execution/
action.rs

1//! Sub-agent execution for accepted triggers — the detached task body spawned by
2//! [`TriggerExecutor::spawn_trigger_action`](super::TriggerExecutor::spawn_trigger_action).
3//!
4//! Covers the three delivery modes ([`TriggerDelivery`]): `SubAgent` (run a fresh
5//! sub-agent), `InjectSummary` (promote `payload_summary` directly, no model call) and
6//! `InjectAndRun` (inject the prompt into the parent loop and request one parent turn).
7
8use std::sync::Arc;
9
10use parking_lot::Mutex;
11use theway_core::agent::session::session::Session;
12use theway_core::types::{
13    AfterToolCallHook, AgentMessage, BeforeToolCallHook, StreamFn, ThinkingLevel,
14};
15use theway_core::{
16    Agent, AgentOptions, AgentRunError, AgentState, AgentTool, LoopEvent, SessionError,
17};
18use theway_llm_provider::{Message as PiMessage, Model};
19
20use crate::trigger_engine::event::{TriggerEvent, TriggerListener};
21use crate::trigger_engine::runtime::TriggerRuntimeSnapshot;
22use crate::trigger_engine::types::Trigger;
23
24use super::RunningTriggerHandle;
25use super::promotion::{
26    PROMOTION_BODY_CAP_BYTES, apply_promotion, compute_sub_agent_outcome, ensure_trigger_prefix,
27    truncate_on_char_boundary,
28};
29use super::types::{
30    BeforeTriggerActionContext, BeforeTriggerActionHook, RunningTriggerState, TriggerAction,
31    TriggerDelivery,
32};
33use super::utils::{emit_from_listeners, preview_for_banner};
34
35/// Top-level body of the spawned sub-agent task. Drives the lifecycle:
36/// 1. Resolve the `TriggerAction` via `before_trigger_action` hook (or default).
37/// 2. Register the trigger as in-flight (`running_triggers`) + emit
38///    `TriggerExecutionStarted`.
39/// 3. Build the sub-agent's `Agent` on an in-memory session, inheriting the parent model,
40///    system prompt, tools, thinking level, and tool hooks. It does not inherit the parent
41///    conversation messages unless a later promotion writes trigger output back.
42/// 4. Race `agent.prompt(action.prompt)` against the cancel token via `tokio::select!`.
43/// 5. Compute `(success, summary, cost_usd)` from the agent's final state.
44/// 6. Write the `trigger_result` audit entry to the **parent** session.
45/// 7. Emit `TriggerCompleted` or `TriggerFailed`.
46/// 8. Remove the trigger from `running_triggers`.
47#[allow(clippy::too_many_arguments)]
48pub(super) async fn run_trigger_action(
49    trigger: Trigger,
50    trace_id: String,
51    source_label: String,
52    event_label: String,
53    listeners: Arc<Mutex<Vec<TriggerListener>>>,
54    parent_session: Session,
55    parent_agent: Arc<Agent>,
56    running_registry: Arc<Mutex<std::collections::HashMap<String, RunningTriggerHandle>>>,
57    action_hook: Option<BeforeTriggerActionHook>,
58    runtime_snapshot: TriggerRuntimeSnapshot,
59    parent_model: Option<Model>,
60    parent_system_prompt: String,
61    parent_tools: Vec<Arc<dyn AgentTool>>,
62    parent_thinking: Option<ThinkingLevel>,
63    stream_fn: Option<StreamFn>,
64    before_tool_call: Option<BeforeToolCallHook>,
65    after_tool_call: Option<AfterToolCallHook>,
66) {
67    // 1. Resolve action. Cancel token is the same one we'll race the agent loop against —
68    // the hook can listen for it to abort a long-running rule/permission UI cleanly.
69    let cancel = tokio_util::sync::CancellationToken::new();
70    let action = match action_hook {
71        Some(hook) => {
72            let ctx = BeforeTriggerActionContext {
73                trigger: trigger.clone(),
74                runtime: runtime_snapshot,
75            };
76            hook(ctx, cancel.clone()).await
77        }
78        None => TriggerAction::default_for(&trigger),
79    };
80
81    // 1b. Direct-inject delivery. Skip the sub-agent entirely and promote
82    // `trigger.payload_summary` straight into the parent loop via `apply_promotion`. No
83    // model call, no tools, cost is a real 0.0. The kernel stays domain-agnostic — it only
84    // moves the opaque summary string and never learns what the source is. We still emit the
85    // ExecutionStarted/Completed pair and a `trigger_result` audit (with `message_count: 0`
86    // distinguishing it from a sub-agent run) so `/triggers` and jsonl readers see a normal
87    // terminal lifecycle.
88    if action.delivery == TriggerDelivery::InjectSummary {
89        let summary = trigger.payload_summary.clone();
90        emit_from_listeners(
91            &listeners,
92            TriggerEvent::TriggerExecutionStarted {
93                trace_id: trace_id.clone(),
94                source_label: source_label.clone(),
95                event_label: event_label.clone(),
96                prompt_preview: preview_for_banner(
97                    summary.as_deref().unwrap_or("(no summary)"),
98                    80,
99                ),
100            },
101        );
102        let result_data = serde_json::json!({
103            "trace_id": trace_id,
104            "branch_id": serde_json::Value::Null,
105            "success": true,
106            "summary": summary,
107            "message_count": 0,
108            // Honest measurement: an inject performs no model call, unlike the sub-agent
109            // path which reports `null` because its bare `Agent` has no CostTracker.
110            "cost_usd": 0.0,
111            "reason": serde_json::Value::Null,
112            "details": serde_json::Value::Null,
113            "delivery": "inject_summary",
114        });
115        if let Err(e) = parent_session
116            .append_custom("trigger_result", Some(result_data))
117            .await
118        {
119            emit_from_listeners(
120                &listeners,
121                TriggerEvent::PersistenceError {
122                    context: "trigger_result".into(),
123                    message: format!("trigger_result (inject) append failed: {:?}", e.code),
124                },
125            );
126        }
127        emit_from_listeners(
128            &listeners,
129            TriggerEvent::TriggerCompleted {
130                trace_id: trace_id.clone(),
131                summary: summary.clone(),
132                cost_usd: Some(0.0),
133                details: serde_json::Value::Null,
134            },
135        );
136        // Reuse the full promotion machinery: prefix enforcement, streaming/idle injection,
137        // dedup, and the `trigger_promotion` audit. `summary` carries the payload summary, so
138        // a `{{trigger.payload_summary}}` (or `{{result.summary}}`) template renders it.
139        apply_promotion(
140            &listeners,
141            &parent_session,
142            &parent_agent,
143            &trace_id,
144            &trigger,
145            true,
146            &summary,
147            0,
148            None,
149            &action.promote,
150            action.promote_requires_approval,
151            &serde_json::Value::Null,
152        )
153        .await;
154        return;
155    }
156
157    // 1c. Inject-and-run delivery. Inject `action.prompt` (a user-rule instruction carrying
158    // whatever source context the rule chose) into the PARENT conversation, then arrange for
159    // ONE model turn in the parent's full context. The kernel never runs the single-tenant
160    // parent agent from this detached task:
161    //   * streaming → enqueue a follow-up; the in-flight loop runs it at the next boundary.
162    //   * idle      → append the message + emit `TriggerRequestsMainRun`; the embedder (which
163    //                 owns the parent agent) schedules the turn on its own serialized loop.
164    // The model turn itself is a normal parent-loop event, NOT attributed to this
165    // `trigger_result` (whose `message_count` stays 0 — this action only injects + requests).
166    if action.delivery == TriggerDelivery::InjectAndRun {
167        let (body, _truncated) =
168            truncate_on_char_boundary(action.prompt.clone(), PROMOTION_BODY_CAP_BYTES);
169        // Same engine-enforced `[Trigger <id>] ` prefix as promotion, so an injected
170        // instruction is never indistinguishable from human input.
171        let (body, prefix_injected) = ensure_trigger_prefix(body, &trace_id);
172        emit_from_listeners(
173            &listeners,
174            TriggerEvent::TriggerExecutionStarted {
175                trace_id: trace_id.clone(),
176                source_label: source_label.clone(),
177                event_label: event_label.clone(),
178                prompt_preview: preview_for_banner(&body, 80),
179            },
180        );
181
182        let user_message = AgentMessage::Llm(PiMessage::User(theway_llm_provider::UserMessage {
183            role: theway_llm_provider::UserRole::User,
184            content: theway_llm_provider::UserContent::Text(body.clone()),
185            timestamp: chrono::Utc::now().timestamp_millis(),
186        }));
187
188        // Inject. Mirror `apply_promotion`'s two-branch persistence so the message lands in
189        // the jsonl exactly once and in the right order relative to any in-flight turn.
190        let queued_for_followup = parent_agent.is_streaming();
191        if queued_for_followup {
192            parent_agent.enqueue_follow_up(user_message);
193        } else if let Err(e) = parent_session.append_message(user_message.clone()).await {
194            emit_from_listeners(
195                &listeners,
196                TriggerEvent::PersistenceError {
197                    context: "trigger_inject_and_run".into(),
198                    message: format!("inject_and_run append failed: {:?}", e.code),
199                },
200            );
201        } else {
202            parent_agent.state().messages.push(user_message);
203        }
204
205        let result_data = serde_json::json!({
206            "trace_id": trace_id,
207            "branch_id": serde_json::Value::Null,
208            "success": true,
209            "summary": body,
210            "message_count": 0,
211            "cost_usd": 0.0,
212            "reason": serde_json::Value::Null,
213            "details": serde_json::Value::Null,
214            "delivery": "inject_and_run",
215            "prefix_injected": prefix_injected,
216            "run_dispatch": if queued_for_followup { "follow_up" } else { "main_run_request" },
217        });
218        if let Err(e) = parent_session
219            .append_custom("trigger_result", Some(result_data))
220            .await
221        {
222            emit_from_listeners(
223                &listeners,
224                TriggerEvent::PersistenceError {
225                    context: "trigger_result".into(),
226                    message: format!(
227                        "trigger_result (inject_and_run) append failed: {:?}",
228                        e.code
229                    ),
230                },
231            );
232        }
233
234        emit_from_listeners(
235            &listeners,
236            TriggerEvent::TriggerCompleted {
237                trace_id: trace_id.clone(),
238                summary: Some(body),
239                cost_usd: Some(0.0),
240                details: serde_json::Value::Null,
241            },
242        );
243
244        // Idle parent: no in-flight loop to drain the follow-up, so ask the embedder to run
245        // one turn. Streaming parent already has the follow-up queued.
246        if !queued_for_followup {
247            emit_from_listeners(
248                &listeners,
249                TriggerEvent::TriggerRequestsMainRun {
250                    trace_id: trace_id.clone(),
251                },
252            );
253        }
254        return;
255    }
256
257    // 2. Register as in-flight + emit ExecutionStarted. The preview is bounded to ~80 chars
258    // because TUI banners cannot render arbitrary user content safely; the full prompt
259    // remains audited through the sub-agent's own jsonl when 5c lands the retained branch.
260    let prompt_preview = preview_for_banner(&action.prompt, 80);
261    let started_at = chrono::Utc::now();
262    {
263        let mut reg = running_registry.lock();
264        reg.insert(
265            trace_id.clone(),
266            RunningTriggerHandle {
267                state: RunningTriggerState {
268                    trace_id: trace_id.clone(),
269                    source_label: source_label.clone(),
270                    event_label: event_label.clone(),
271                    started_at,
272                    prompt_preview: prompt_preview.clone(),
273                },
274                cancel: cancel.clone(),
275            },
276        );
277    }
278    emit_from_listeners(
279        &listeners,
280        TriggerEvent::TriggerExecutionStarted {
281            trace_id: trace_id.clone(),
282            source_label: source_label.clone(),
283            event_label: event_label.clone(),
284            prompt_preview,
285        },
286    );
287
288    // 3. Build sub-agent. It receives the parent's already-rendered system prompt, tool
289    // list, and hooks. That means model-facing skill catalog text and the live Skill tool
290    // remain available to trigger actions, but parent conversation messages are not copied
291    // into the trigger run. In sub-PR 5a the sub-agent transcript lives in memory only and
292    // is discarded when this task finishes. Per the issue #20 amendment, persisted
293    // retained branches land in sub-PR 5c. The `trigger_result.summary` we persist to the
294    // parent session is the only durable record of what the sub-agent produced in 5a.
295    let sub_storage: Arc<dyn theway_core::agent::session::session::SessionStorage> =
296        Arc::new(theway_core::agent::session::memory_storage::MemorySessionStorage::new());
297    let sub_session = theway_core::agent::session::session::Session::new(sub_storage);
298
299    let mut sub_state = AgentState::default();
300    sub_state.model = parent_model;
301    sub_state.thinking_level = parent_thinking;
302    sub_state.tools = parent_tools;
303    sub_state.system_prompt = parent_system_prompt;
304
305    let sub_agent = Agent::new(AgentOptions {
306        initial_state: Some(sub_state),
307        stream_fn,
308        before_tool_call,
309        after_tool_call,
310        observer: parent_agent.runtime_observer(),
311        observation_context: parent_agent.observation_context(),
312        observation_parent: parent_agent.active_run_operation(),
313        ..Default::default()
314    });
315
316    // Persist sub-agent messages into the sub-session jsonl as they finalize. Even though
317    // the storage is in-memory in 5a, this keeps the message-stream → session-state link
318    // intact so 5c's jsonl swap is a pure storage change with no agent-loop refactor.
319    let persist_errors: Arc<Mutex<Vec<SessionError>>> = Arc::new(Mutex::new(Vec::new()));
320    let persist_session = sub_session.clone();
321    let persist_errors_listener = persist_errors.clone();
322    let _persist_unsub = sub_agent.subscribe(Arc::new(move |event, _cancel| {
323        let session = persist_session.clone();
324        let sink = persist_errors_listener.clone();
325        Box::pin(async move {
326            if let LoopEvent::MessageEnd { message } = event {
327                if let Err(e) = session.append_message(message).await {
328                    sink.lock().push(e);
329                }
330            }
331        })
332    }));
333
334    // 4. Race agent.prompt against cancel. The sub-agent receives the resolved action
335    // prompt as a user message. On abort we propagate to the sub-agent's own
336    // CancellationToken via `Agent::abort()`.
337    let user_message = AgentMessage::Llm(PiMessage::User(theway_llm_provider::UserMessage {
338        role: theway_llm_provider::UserRole::User,
339        content: theway_llm_provider::UserContent::Text(action.prompt.clone()),
340        timestamp: chrono::Utc::now().timestamp_millis(),
341    }));
342    let run_outcome: Result<(), AgentRunError> = tokio::select! {
343        biased;
344        _ = cancel.cancelled() => {
345            sub_agent.abort();
346            Err(AgentRunError::Other("aborted".into()))
347        }
348        res = sub_agent.prompt(user_message) => res,
349    };
350
351    // 5. Compute summary. The sub-agent's final assistant message is our best
352    // first-cut summary for 5a (no model-driven self-summary yet — that's a 5b polish).
353    let (success, summary, message_count) = compute_sub_agent_outcome(&sub_agent, &run_outcome);
354    // Compute failure reason once (used in both the audit and the terminal event so the
355    // jsonl record carries enough context to explain `success: false` after `--resume`).
356    let failure_reason: Option<String> = if success {
357        None
358    } else {
359        Some(match &run_outcome {
360            Err(AgentRunError::Other(msg)) if msg == "aborted" => "aborted".to_string(),
361            Err(e) => format!("{e}"),
362            Ok(_) => "unknown failure".to_string(),
363        })
364    };
365
366    // 6. Persist `trigger_result` to PARENT session. Best-effort: on failure we emit a
367    // `PersistenceError` reflux event (same shape as `trigger_audit` failures in sub-PR 2)
368    // but still proceed to remove from registry + emit terminal event.
369    //
370    // `cost_usd` is omitted (Option/null) in 5a because the bare sub-`Agent` here has no
371    // `CostTracker` wrapper — the parent `AgentHarness::cost` only auto-accrues for the
372    // parent's own listener. Sub-PR 5b/5c will add a sub-harness wrapper or hook the
373    // sub-agent's `MessageEnd` events into the parent `CostTracker`. Reporting `0.0`
374    // today would lie about a real measurement; `null` honestly says "unknown".
375    //
376    // `details` is the structured sub-agent result envelope per RFC 1 §5.C: marker tools
377    // (`mark_dynamic_rule_matched` and future per-source equivalents) write through the
378    // [`TriggerResultDetailsBuilder`] accumulator while the sub-agent runs; runtime
379    // snapshots the builder here. Until callers wire a builder into the sub-agent, this is
380    // `Null` and any `PromoteAction::PromoteSummaryWhenResultDetailsMatch` evaluation
381    // fails closed with `PromotionConditionSkipReason::PointerMissing` — the safe default.
382    let details_for_promotion: serde_json::Value = serde_json::Value::Null;
383    let result_data = serde_json::json!({
384        "trace_id": trace_id,
385        "branch_id": serde_json::Value::Null,
386        "success": success,
387        "summary": summary,
388        "message_count": message_count,
389        "cost_usd": serde_json::Value::Null,
390        "reason": failure_reason,
391        "details": details_for_promotion,
392    });
393    let audit_write_result = parent_session
394        .append_custom("trigger_result", Some(result_data))
395        .await;
396    if let Err(e) = audit_write_result {
397        emit_from_listeners(
398            &listeners,
399            TriggerEvent::PersistenceError {
400                context: "trigger_result".into(),
401                message: format!("trigger_result append failed: {:?}", e.code),
402            },
403        );
404    }
405    // Also surface any sub-agent-side persist errors so they aren't silently swallowed.
406    for e in persist_errors.lock().iter() {
407        emit_from_listeners(
408            &listeners,
409            TriggerEvent::PersistenceError {
410                context: "trigger_result".into(),
411                message: format!("sub-agent session append failed: {:?}", e.code),
412            },
413        );
414    }
415
416    // 7. Terminal event. `reason` for Failed is sanitized: we pass the `AgentRunError`'s
417    // `Display` (free-form but generally short error string from our own code paths) and
418    // explicitly avoid embedding any sub-agent message bodies / provider response content.
419    if success {
420        // `cost_usd: None` mirrors the audit's `cost_usd: null`. Sub-agent in 5a is bare
421        // (no CostTracker wrapper); reporting 0.0 here while the audit said null would
422        // make event subscribers + jsonl readers disagree about the same field. 5b/5c
423        // will populate this with a real measurement when the sub-agent is wrapped.
424        emit_from_listeners(
425            &listeners,
426            TriggerEvent::TriggerCompleted {
427                trace_id: trace_id.clone(),
428                // Resolution after 5a merge: HEAD (main) has cost_usd: Option<f64> = None
429                // per CLI-TUI review (3845107). 5b needs summary.clone() because the
430                // promotion step below consumes `summary` by reference. Combine both.
431                summary: summary.clone(),
432                cost_usd: None,
433                details: details_for_promotion.clone(),
434            },
435        );
436    } else {
437        emit_from_listeners(
438            &listeners,
439            TriggerEvent::TriggerFailed {
440                trace_id: trace_id.clone(),
441                reason: failure_reason
442                    .clone()
443                    .unwrap_or_else(|| "unknown failure".to_string()),
444            },
445        );
446    }
447
448    // 7b. Promotion. RFC 1 §5.C: `PromoteAction` decides whether (and how) the
449    // `trigger_result` is mirrored back into the parent transcript / LLM context. Runs
450    // AFTER the terminal `TriggerCompleted | TriggerFailed` so the event order pinned in
451    // RFC 1 §5.F holds. Promotion outcomes are themselves emitted + audited as
452    // `TriggerPromoted | PromotionPending` + `Custom { custom_type: "trigger_promotion" }`.
453    apply_promotion(
454        &listeners,
455        &parent_session,
456        &parent_agent,
457        &trace_id,
458        &trigger,
459        success,
460        &summary,
461        message_count,
462        failure_reason.as_deref(),
463        &action.promote,
464        action.promote_requires_approval,
465        // Sub-agent result details. Populated via marker tools that write through the
466        // [`TriggerResultDetailsBuilder`] accumulator (sub-PR for marker-tool wiring lands
467        // separately). Until that wires in, this stays `Null` and any caller using
468        // `PromoteAction::PromoteSummaryWhenResultDetailsMatch` will fail closed with
469        // `PromotionConditionSkipReason::PointerMissing` — the safe default.
470        &details_for_promotion,
471    )
472    .await;
473
474    // 8. Remove from registry.
475    running_registry.lock().remove(&trace_id);
476}
477
478#[cfg(test)]
479// Test files live in `tests/trigger_engine/execution/action/` (mirror of src),
480// pulled in by path so they keep unit-test semantics (private access).
481// See docs/rust-test-files.md.
482tests_bridge_macro::tests_bridge!("trigger_engine/execution/action");