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            account_id: None,
275            environment_id: None,
276        };
277
278        let completed_at = Utc::now();
279        self.store
280            .update_step(
281                step.id,
282                StepUpdate {
283                    status: Some(StepStatus::Completed),
284                    output: Some(output_value),
285                    duration_ms: Some(execution.duration_ms),
286                    cost_usd: Some(execution.cost_usd),
287                    input_tokens: Some(execution.input_tokens),
288                    output_tokens: Some(execution.output_tokens),
289                    completed_at: Some(completed_at),
290                    ..StepUpdate::default()
291                },
292            )
293            .await?;
294
295        self.step_results
296            .push(StepResult::from_success(trace_id, name, &step_output));
297        self.persist_progress().await;
298        self.last_step_ids = vec![step.id];
299
300        info!(
301            run_id = %self.run_id,
302            step = %name,
303            trace_id = %trace_id,
304            cost_usd = %execution.cost_usd,
305            "decision step completed"
306        );
307
308        if let Some(ref bus) = self.event_bus {
309            bus.publish(
310                self.run_id,
311                WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
312                    step_name: name.to_string(),
313                    step_index: position,
314                    duration_ms: execution.duration_ms,
315                    output_summary: None,
316                }),
317            );
318        }
319
320        Ok(execution.output)
321    }
322
323    /// Persist an escalated decision, suspend the run, and return
324    /// [`EngineError::ApprovalRequired`].
325    async fn decision_escalate(
326        &mut self,
327        name: &str,
328        position: u32,
329        step_id: Uuid,
330        config: &DecisionConfig,
331        execution: &DecisionExecution,
332        output_value: Value,
333    ) -> Result<DecisionOutput, EngineError> {
334        let threshold = config.escalate_below.unwrap_or_default();
335        let min = execution.output.min_confidence().unwrap_or_default();
336
337        self.store
338            .update_step(
339                step_id,
340                StepUpdate {
341                    status: Some(StepStatus::AwaitingApproval),
342                    output: Some(output_value),
343                    duration_ms: Some(execution.duration_ms),
344                    cost_usd: Some(execution.cost_usd),
345                    input_tokens: Some(execution.input_tokens),
346                    output_tokens: Some(execution.output_tokens),
347                    ..StepUpdate::default()
348                },
349            )
350            .await?;
351        self.last_step_ids = vec![step_id];
352
353        info!(
354            run_id = %self.run_id,
355            step = %name,
356            position,
357            confidence = min,
358            threshold,
359            "decision escalated to human approval"
360        );
361
362        if let Some(ref bus) = self.event_bus {
363            bus.publish(
364                self.run_id,
365                WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
366                    step_name: name.to_string(),
367                    step_index: position,
368                    approval_id: step_id,
369                }),
370            );
371        }
372
373        Err(EngineError::ApprovalRequired {
374            run_id: self.run_id,
375            step_id,
376            message: format!(
377                "decision '{name}' escalated: confidence {min:.3} below threshold {threshold:.3}"
378            ),
379        })
380    }
381}