Skip to main content

ironflow_engine/context/steps/
decision.rs

1//! Typed machine-decision step for [`WorkflowContext`].
2//!
3//! Holds the public [`decision`](WorkflowContext::decision) entry point and the
4//! replay, execute and escalate paths it dispatches to. As a descendant module
5//! of `context`, it can access `WorkflowContext`'s private fields.
6//!
7//! The decision step is a hybrid of an agent step (it calls a provider, costs
8//! money, and stores typed output) and an approval gate (a low-confidence answer
9//! suspends the run and replays its stored answers on resume).
10
11use std::collections::BTreeMap;
12
13use chrono::Utc;
14use serde_json::{Value, from_value, to_value};
15use tracing::info;
16use uuid::Uuid;
17
18use ironflow_core::decision::{DecisionOutput, DecisionUsage};
19use ironflow_store::models::{NewStep, StepKind, StepStatus, StepUpdate, step_trace_id};
20
21use crate::config::DecisionConfig;
22use crate::context::WorkflowContext;
23use crate::decision::DecisionAnswers;
24use crate::error::EngineError;
25use crate::executor::{DecisionExecution, StepArtifacts, StepOutput, StepResult, execute_decision};
26use crate::notify::{
27    WorkflowApprovalRequiredEvent, WorkflowEvent, WorkflowStepCompletedEvent,
28    WorkflowStepStartedEvent,
29};
30use crate::plan::lock_plan;
31
32impl WorkflowContext {
33    /// Execute a typed machine-decision step (System One / Jev).
34    ///
35    /// The questions come from `T`, set with
36    /// [`DecisionConfig::answers`], and the answers are returned as a `T`. See
37    /// [`crate::decision`]. When `escalate_below` is set and any answer falls
38    /// below it, the run suspends with [`EngineError::ApprovalRequired`] and
39    /// replays the stored answers on resume without re-calling the provider.
40    ///
41    /// While planning no provider is called and no answer exists, so reading
42    /// them fails with [`EngineError::Decision`] and the plan stops at this
43    /// step.
44    ///
45    /// # Errors
46    ///
47    /// [`EngineError::NoDecisionProvider`], [`EngineError::ApprovalRequired`],
48    /// [`EngineError::Operation`], or [`EngineError::Decision`] when an answer
49    /// does not fit `T`.
50    ///
51    /// # Examples
52    ///
53    /// ```no_run
54    /// use ironflow_engine::config::DecisionConfig;
55    /// use ironflow_engine::context::WorkflowContext;
56    /// use ironflow_engine::decision::DecisionAnswers;
57    /// use ironflow_engine::error::EngineError;
58    ///
59    /// #[derive(DecisionAnswers)]
60    /// struct Urgency {
61    ///     #[noul("Does this convey urgency?")]
62    ///     urgent: f64,
63    /// }
64    ///
65    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
66    /// let urgency = ctx
67    ///     .decision("urgency", DecisionConfig::new("Payouts fail since 3 days").answers::<Urgency>())
68    ///     .await?;
69    /// if urgency.urgent > 0.8 {
70    ///     // page someone
71    /// }
72    /// # Ok(())
73    /// # }
74    /// ```
75    ///
76    /// A config without questions is not a decision:
77    ///
78    /// ```compile_fail,E0277
79    /// # use ironflow_engine::config::DecisionConfig;
80    /// # use ironflow_engine::context::WorkflowContext;
81    /// # use ironflow_engine::error::EngineError;
82    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
83    /// ctx.decision("urgency", DecisionConfig::new("state")).await?;
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub async fn decision<T: DecisionAnswers>(
88        &mut self,
89        name: &str,
90        config: DecisionConfig<T>,
91    ) -> Result<T, EngineError> {
92        let output = self.decision_output(name, config.erase()).await?;
93        Ok(T::from_output(&output)?)
94    }
95
96    /// Run, replay or plan a decision step and return the raw answers.
97    async fn decision_output(
98        &mut self,
99        name: &str,
100        config: DecisionConfig,
101    ) -> Result<DecisionOutput, EngineError> {
102        // Plan mode: record the step and return an empty answer set. No
103        // provider is called, so the step costs nothing while planning.
104        if let Some(plan) = self.plan().cloned() {
105            self.position += 1;
106            let mut recorder = lock_plan(&plan);
107            if recorder.record(name, StepKind::Decision, &self.workflow_name, None) {
108                recorder.set_last(vec![name.to_string()]);
109            }
110            return Ok(DecisionOutput {
111                model: None,
112                answers: BTreeMap::new(),
113                usage: DecisionUsage::default(),
114            });
115        }
116
117        if let Some(output) = self.decision_replay(name, &config).await? {
118            return Ok(output);
119        }
120        self.decision_execute(name, config).await
121    }
122
123    /// Replay a decision step recorded in this attempt, if any.
124    ///
125    /// Returns `Ok(Some(output))` when the step at the current position was
126    /// already decided (its answers are returned as-is, without re-calling the
127    /// provider), advancing the position. Returns `Ok(None)` when there is
128    /// nothing to replay, leaving the position untouched for a fresh execution.
129    async fn decision_replay(
130        &mut self,
131        name: &str,
132        _config: &DecisionConfig,
133    ) -> Result<Option<DecisionOutput>, EngineError> {
134        let position = self.position;
135
136        let Some(existing) = self.replay_steps.get(&position).cloned() else {
137            return Ok(None);
138        };
139        if existing.kind != StepKind::Decision {
140            return Ok(None);
141        }
142
143        self.position += 1;
144
145        let stored: DecisionOutput = existing
146            .output
147            .clone()
148            .ok_or_else(|| {
149                EngineError::StepConfig(format!(
150                    "decision step '{name}' has no stored output to replay"
151                ))
152            })
153            .and_then(|v| from_value(v).map_err(EngineError::from))?;
154
155        // An escalated decision suspended in `AwaitingApproval`; the handler only
156        // re-runs on an approved resume, so mark it completed and continue.
157        if existing.status.state == StepStatus::AwaitingApproval {
158            self.store
159                .update_step(
160                    existing.id,
161                    StepUpdate {
162                        status: Some(StepStatus::Completed),
163                        completed_at: Some(Utc::now()),
164                        ..StepUpdate::default()
165                    },
166                )
167                .await?;
168            info!(
169                run_id = %self.run_id,
170                step = %name,
171                position,
172                "decision step replayed (approved after escalation)"
173            );
174        } else {
175            info!(
176                run_id = %self.run_id,
177                step = %name,
178                position,
179                "decision step replayed from previous execution"
180            );
181        }
182
183        // Do not re-add cost/duration here. A replay only happens on a resume
184        // within the same attempt, where `carry_over_run_totals` has already
185        // seeded `total_cost_usd`/`total_duration_ms` from the run totals the
186        // suspend snapshot persisted -- totals that already include this step.
187        // Adding them again would double-count the escalated decision.
188        self.last_step_ids = vec![existing.id];
189        Ok(Some(stored))
190    }
191
192    /// Execute a fresh decision step: call the provider, persist the answers, and
193    /// either complete or escalate to a human approval gate.
194    async fn decision_execute(
195        &mut self,
196        name: &str,
197        config: DecisionConfig,
198    ) -> Result<DecisionOutput, EngineError> {
199        self.check_guard_timeout()?;
200
201        let position = self.position;
202        self.position += 1;
203
204        let provider =
205            self.decision_provider
206                .clone()
207                .ok_or_else(|| EngineError::NoDecisionProvider {
208                    step: name.to_string(),
209                })?;
210
211        let trace_id = step_trace_id(self.run_id, name, position);
212        let step = self
213            .store
214            .create_step(NewStep {
215                run_id: self.run_id,
216                trace_id,
217                name: name.to_string(),
218                kind: StepKind::Decision,
219                position,
220                input: Some(to_value(&config)?),
221                is_error_handler: false,
222            })
223            .await?;
224        self.start_step(step.id, Utc::now()).await?;
225
226        if let Some(ref bus) = self.event_bus {
227            bus.publish(
228                self.run_id,
229                WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
230                    step_name: name.to_string(),
231                    step_index: position,
232                    timestamp: Utc::now(),
233                }),
234            );
235        }
236
237        let execution = match execute_decision(&provider, &config).await {
238            Ok(execution) => execution,
239            Err(err) => {
240                self.fail_step(step.id, &err).await;
241                return Err(err);
242            }
243        };
244
245        // Impute cost and duration to the run, like an agent step.
246        self.total_cost_usd += execution.cost_usd;
247        self.total_duration_ms += execution.duration_ms;
248
249        let output_value = to_value(&execution.output)?;
250
251        let escalated = config
252            .escalate_below
253            .zip(execution.output.min_confidence())
254            .map(|(threshold, min)| min < threshold)
255            .unwrap_or(false);
256
257        if escalated {
258            return self
259                .decision_escalate(name, position, step.id, &config, &execution, output_value)
260                .await;
261        }
262
263        let step_output = StepOutput {
264            output: output_value.clone(),
265            duration_ms: execution.duration_ms,
266            cost_usd: execution.cost_usd,
267            input_tokens: Some(execution.input_tokens),
268            cache_read_input_tokens: None,
269            cache_creation_input_tokens: None,
270            output_tokens: Some(execution.output_tokens),
271            model: execution.output.model.as_ref().map(ToString::to_string),
272            debug_messages: None,
273            artifacts: StepArtifacts::default(),
274        };
275
276        let completed_at = Utc::now();
277        self.store
278            .update_step(
279                step.id,
280                StepUpdate {
281                    status: Some(StepStatus::Completed),
282                    output: Some(output_value),
283                    duration_ms: Some(execution.duration_ms),
284                    cost_usd: Some(execution.cost_usd),
285                    input_tokens: Some(execution.input_tokens),
286                    output_tokens: Some(execution.output_tokens),
287                    completed_at: Some(completed_at),
288                    ..StepUpdate::default()
289                },
290            )
291            .await?;
292
293        self.step_results
294            .push(StepResult::from_success(trace_id, name, &step_output));
295        self.persist_progress().await;
296        self.last_step_ids = vec![step.id];
297
298        info!(
299            run_id = %self.run_id,
300            step = %name,
301            trace_id = %trace_id,
302            cost_usd = %execution.cost_usd,
303            "decision step completed"
304        );
305
306        if let Some(ref bus) = self.event_bus {
307            bus.publish(
308                self.run_id,
309                WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
310                    step_name: name.to_string(),
311                    step_index: position,
312                    duration_ms: execution.duration_ms,
313                    output_summary: None,
314                }),
315            );
316        }
317
318        Ok(execution.output)
319    }
320
321    /// Persist an escalated decision, suspend the run, and return
322    /// [`EngineError::ApprovalRequired`].
323    async fn decision_escalate(
324        &mut self,
325        name: &str,
326        position: u32,
327        step_id: Uuid,
328        config: &DecisionConfig,
329        execution: &DecisionExecution,
330        output_value: Value,
331    ) -> Result<DecisionOutput, EngineError> {
332        let threshold = config.escalate_below.unwrap_or_default();
333        let min = execution.output.min_confidence().unwrap_or_default();
334
335        self.store
336            .update_step(
337                step_id,
338                StepUpdate {
339                    status: Some(StepStatus::AwaitingApproval),
340                    output: Some(output_value),
341                    duration_ms: Some(execution.duration_ms),
342                    cost_usd: Some(execution.cost_usd),
343                    input_tokens: Some(execution.input_tokens),
344                    output_tokens: Some(execution.output_tokens),
345                    ..StepUpdate::default()
346                },
347            )
348            .await?;
349        self.last_step_ids = vec![step_id];
350
351        info!(
352            run_id = %self.run_id,
353            step = %name,
354            position,
355            confidence = min,
356            threshold,
357            "decision escalated to human approval"
358        );
359
360        if let Some(ref bus) = self.event_bus {
361            bus.publish(
362                self.run_id,
363                WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
364                    step_name: name.to_string(),
365                    step_index: position,
366                    approval_id: step_id,
367                }),
368            );
369        }
370
371        Err(EngineError::ApprovalRequired {
372            run_id: self.run_id,
373            step_id,
374            message: format!(
375                "decision '{name}' escalated: confidence {min:.3} below threshold {threshold:.3}"
376            ),
377        })
378    }
379}