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");