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::context::lifecycle::check_replay_identity;
24use crate::decision::DecisionAnswers;
25use crate::error::EngineError;
26use crate::executor::{DecisionExecution, StepArtifacts, StepOutput, StepResult, execute_decision};
27use crate::notify::{
28    WorkflowApprovalRequiredEvent, WorkflowEvent, WorkflowStepCompletedEvent,
29    WorkflowStepStartedEvent,
30};
31use crate::plan::lock_plan;
32
33impl WorkflowContext {
34    /// Execute a typed machine-decision step (System One / Jev).
35    ///
36    /// The questions come from `T`, set with
37    /// [`DecisionConfig::answers`], and the answers are returned as a `T`. See
38    /// [`crate::decision`]. When `escalate_below` is set and any answer falls
39    /// below it, the run suspends with [`EngineError::ApprovalRequired`] and
40    /// replays the stored answers on resume without re-calling the provider.
41    ///
42    /// While planning no provider is called and no answer exists, so reading
43    /// them fails with [`EngineError::Decision`] and the plan stops at this
44    /// step.
45    ///
46    /// # Errors
47    ///
48    /// [`EngineError::NoDecisionProvider`], [`EngineError::ApprovalRequired`],
49    /// [`EngineError::Operation`], [`EngineError::Decision`] when an answer
50    /// does not fit `T`, or [`EngineError::ReplayDivergence`] when the step
51    /// recorded at this position has a different name or kind.
52    ///
53    /// # Examples
54    ///
55    /// ```no_run
56    /// use ironflow_engine::config::DecisionConfig;
57    /// use ironflow_engine::context::WorkflowContext;
58    /// use ironflow_engine::decision::DecisionAnswers;
59    /// use ironflow_engine::error::EngineError;
60    ///
61    /// #[derive(DecisionAnswers)]
62    /// struct Urgency {
63    ///     #[noul("Does this convey urgency?")]
64    ///     urgent: f64,
65    /// }
66    ///
67    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
68    /// let urgency = ctx
69    ///     .decision("urgency", DecisionConfig::new("Payouts fail since 3 days").answers::<Urgency>())
70    ///     .await?;
71    /// if urgency.urgent > 0.8 {
72    ///     // page someone
73    /// }
74    /// # Ok(())
75    /// # }
76    /// ```
77    ///
78    /// A config without questions is not a decision:
79    ///
80    /// ```compile_fail,E0277
81    /// # use ironflow_engine::config::DecisionConfig;
82    /// # use ironflow_engine::context::WorkflowContext;
83    /// # use ironflow_engine::error::EngineError;
84    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
85    /// ctx.decision("urgency", DecisionConfig::new("state")).await?;
86    /// # Ok(())
87    /// # }
88    /// ```
89    pub async fn decision<T: DecisionAnswers>(
90        &mut self,
91        name: &str,
92        config: DecisionConfig<T>,
93    ) -> Result<T, EngineError> {
94        let output = self.decision_output(name, config.erase()).await?;
95        Ok(T::from_output(&output)?)
96    }
97
98    /// Run, replay or plan a decision step and return the raw answers.
99    async fn decision_output(
100        &mut self,
101        name: &str,
102        config: DecisionConfig,
103    ) -> Result<DecisionOutput, EngineError> {
104        // Plan mode: record the step and return an empty answer set. No
105        // provider is called, so the step costs nothing while planning.
106        if let Some(plan) = self.plan().cloned() {
107            self.position += 1;
108            let mut recorder = lock_plan(&plan);
109            if recorder.record(name, StepKind::Decision, &self.workflow_name, None) {
110                recorder.set_last(vec![name.to_string()]);
111            }
112            return Ok(DecisionOutput {
113                model: None,
114                answers: BTreeMap::new(),
115                usage: DecisionUsage::default(),
116            });
117        }
118
119        if let Some(output) = self.decision_replay(name, &config).await? {
120            return Ok(output);
121        }
122        self.decision_execute(name, config).await
123    }
124
125    /// Replay a decision step recorded in this attempt, if any.
126    ///
127    /// Returns `Ok(Some(output))` when the step at the current position was
128    /// already decided (its answers are returned as-is, without re-calling the
129    /// provider), advancing the position. Returns `Ok(None)` when there is
130    /// nothing to replay, leaving the position untouched for a fresh execution.
131    async fn decision_replay(
132        &mut self,
133        name: &str,
134        _config: &DecisionConfig,
135    ) -> Result<Option<DecisionOutput>, EngineError> {
136        let position = self.position;
137
138        let Some(existing) = self.replay_steps.get(&position).cloned() else {
139            return Ok(None);
140        };
141        check_replay_identity(&existing, position, name, &StepKind::Decision)?;
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}