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::error::EngineError;
24use crate::executor::{DecisionExecution, StepOutput, StepResult, execute_decision};
25use crate::notify::{
26    WorkflowApprovalRequiredEvent, WorkflowEvent, WorkflowStepCompletedEvent,
27    WorkflowStepStartedEvent,
28};
29use crate::plan::lock_plan;
30
31impl WorkflowContext {
32    /// Execute a typed machine-decision step (System One / Jev).
33    ///
34    /// See [`DecisionConfig`]. Returns a
35    /// [`DecisionOutput`] whose answers are accessed by name. When
36    /// `escalate_below` is set and any answer falls below it, the run suspends
37    /// with [`EngineError::ApprovalRequired`] and replays the stored answers on
38    /// resume without re-calling the provider.
39    ///
40    /// # Errors
41    ///
42    /// [`EngineError::NoDecisionProvider`], [`EngineError::ApprovalRequired`], or
43    /// [`EngineError::Operation`].
44    pub async fn decision(
45        &mut self,
46        name: &str,
47        config: DecisionConfig,
48    ) -> Result<DecisionOutput, EngineError> {
49        // Plan mode: record the step and return an empty answer set. No
50        // provider is called, so the step costs nothing while planning.
51        if let Some(plan) = self.plan().cloned() {
52            self.position += 1;
53            let mut recorder = lock_plan(&plan);
54            if recorder.record(name, StepKind::Decision, &self.workflow_name, None) {
55                recorder.set_last(vec![name.to_string()]);
56            }
57            return Ok(DecisionOutput {
58                model: None,
59                answers: BTreeMap::new(),
60                usage: DecisionUsage::default(),
61            });
62        }
63
64        if let Some(output) = self.decision_replay(name, &config).await? {
65            return Ok(output);
66        }
67        self.decision_execute(name, config).await
68    }
69
70    /// Replay a decision step recorded in this attempt, if any.
71    ///
72    /// Returns `Ok(Some(output))` when the step at the current position was
73    /// already decided (its answers are returned as-is, without re-calling the
74    /// provider), advancing the position. Returns `Ok(None)` when there is
75    /// nothing to replay, leaving the position untouched for a fresh execution.
76    async fn decision_replay(
77        &mut self,
78        name: &str,
79        _config: &DecisionConfig,
80    ) -> Result<Option<DecisionOutput>, EngineError> {
81        let position = self.position;
82
83        let Some(existing) = self.replay_steps.get(&position).cloned() else {
84            return Ok(None);
85        };
86        if existing.kind != StepKind::Decision {
87            return Ok(None);
88        }
89
90        self.position += 1;
91
92        let stored: DecisionOutput = existing
93            .output
94            .clone()
95            .ok_or_else(|| {
96                EngineError::StepConfig(format!(
97                    "decision step '{name}' has no stored output to replay"
98                ))
99            })
100            .and_then(|v| from_value(v).map_err(EngineError::from))?;
101
102        // An escalated decision suspended in `AwaitingApproval`; the handler only
103        // re-runs on an approved resume, so mark it completed and continue.
104        if existing.status.state == StepStatus::AwaitingApproval {
105            self.store
106                .update_step(
107                    existing.id,
108                    StepUpdate {
109                        status: Some(StepStatus::Completed),
110                        completed_at: Some(Utc::now()),
111                        ..StepUpdate::default()
112                    },
113                )
114                .await?;
115            info!(
116                run_id = %self.run_id,
117                step = %name,
118                position,
119                "decision step replayed (approved after escalation)"
120            );
121        } else {
122            info!(
123                run_id = %self.run_id,
124                step = %name,
125                position,
126                "decision step replayed from previous execution"
127            );
128        }
129
130        // Do not re-add cost/duration here. A replay only happens on a resume
131        // within the same attempt, where `carry_over_run_totals` has already
132        // seeded `total_cost_usd`/`total_duration_ms` from the run totals the
133        // suspend snapshot persisted -- totals that already include this step.
134        // Adding them again would double-count the escalated decision.
135        self.last_step_ids = vec![existing.id];
136        Ok(Some(stored))
137    }
138
139    /// Execute a fresh decision step: call the provider, persist the answers, and
140    /// either complete or escalate to a human approval gate.
141    async fn decision_execute(
142        &mut self,
143        name: &str,
144        config: DecisionConfig,
145    ) -> Result<DecisionOutput, EngineError> {
146        self.check_guard_timeout()?;
147
148        let position = self.position;
149        self.position += 1;
150
151        let provider =
152            self.decision_provider
153                .clone()
154                .ok_or_else(|| EngineError::NoDecisionProvider {
155                    step: name.to_string(),
156                })?;
157
158        let trace_id = step_trace_id(self.run_id, name, position);
159        let step = self
160            .store
161            .create_step(NewStep {
162                run_id: self.run_id,
163                trace_id,
164                name: name.to_string(),
165                kind: StepKind::Decision,
166                position,
167                input: Some(to_value(&config)?),
168                is_error_handler: false,
169            })
170            .await?;
171        self.start_step(step.id, Utc::now()).await?;
172
173        if let Some(ref bus) = self.event_bus {
174            bus.publish(
175                self.run_id,
176                WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
177                    step_name: name.to_string(),
178                    step_index: position,
179                    timestamp: Utc::now(),
180                }),
181            );
182        }
183
184        let execution = match execute_decision(&provider, &config).await {
185            Ok(execution) => execution,
186            Err(err) => {
187                self.fail_step(step.id, &err).await;
188                return Err(err);
189            }
190        };
191
192        // Impute cost and duration to the run, like an agent step.
193        self.total_cost_usd += execution.cost_usd;
194        self.total_duration_ms += execution.duration_ms;
195
196        let output_value = to_value(&execution.output)?;
197
198        let escalated = config
199            .escalate_below
200            .zip(execution.output.min_confidence())
201            .map(|(threshold, min)| min < threshold)
202            .unwrap_or(false);
203
204        if escalated {
205            return self
206                .decision_escalate(name, position, step.id, &config, &execution, output_value)
207                .await;
208        }
209
210        let step_output = StepOutput {
211            output: output_value.clone(),
212            duration_ms: execution.duration_ms,
213            cost_usd: execution.cost_usd,
214            input_tokens: Some(execution.input_tokens),
215            output_tokens: Some(execution.output_tokens),
216            model: execution.output.model.as_ref().map(ToString::to_string),
217            debug_messages: None,
218        };
219
220        let completed_at = Utc::now();
221        self.store
222            .update_step(
223                step.id,
224                StepUpdate {
225                    status: Some(StepStatus::Completed),
226                    output: Some(output_value),
227                    duration_ms: Some(execution.duration_ms),
228                    cost_usd: Some(execution.cost_usd),
229                    input_tokens: Some(execution.input_tokens),
230                    output_tokens: Some(execution.output_tokens),
231                    completed_at: Some(completed_at),
232                    ..StepUpdate::default()
233                },
234            )
235            .await?;
236
237        self.step_results
238            .push(StepResult::from_success(trace_id, name, &step_output));
239        self.persist_progress().await;
240        self.last_step_ids = vec![step.id];
241
242        info!(
243            run_id = %self.run_id,
244            step = %name,
245            trace_id = %trace_id,
246            cost_usd = %execution.cost_usd,
247            "decision step completed"
248        );
249
250        if let Some(ref bus) = self.event_bus {
251            bus.publish(
252                self.run_id,
253                WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
254                    step_name: name.to_string(),
255                    step_index: position,
256                    duration_ms: execution.duration_ms,
257                    output_summary: None,
258                }),
259            );
260        }
261
262        Ok(execution.output)
263    }
264
265    /// Persist an escalated decision, suspend the run, and return
266    /// [`EngineError::ApprovalRequired`].
267    async fn decision_escalate(
268        &mut self,
269        name: &str,
270        position: u32,
271        step_id: Uuid,
272        config: &DecisionConfig,
273        execution: &DecisionExecution,
274        output_value: Value,
275    ) -> Result<DecisionOutput, EngineError> {
276        let threshold = config.escalate_below.unwrap_or_default();
277        let min = execution.output.min_confidence().unwrap_or_default();
278
279        self.store
280            .update_step(
281                step_id,
282                StepUpdate {
283                    status: Some(StepStatus::AwaitingApproval),
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                    ..StepUpdate::default()
290                },
291            )
292            .await?;
293        self.last_step_ids = vec![step_id];
294
295        info!(
296            run_id = %self.run_id,
297            step = %name,
298            position,
299            confidence = min,
300            threshold,
301            "decision escalated to human approval"
302        );
303
304        if let Some(ref bus) = self.event_bus {
305            bus.publish(
306                self.run_id,
307                WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
308                    step_name: name.to_string(),
309                    step_index: position,
310                    approval_id: step_id,
311                }),
312            );
313        }
314
315        Err(EngineError::ApprovalRequired {
316            run_id: self.run_id,
317            step_id,
318            message: format!(
319                "decision '{name}' escalated: confidence {min:.3} below threshold {threshold:.3}"
320            ),
321        })
322    }
323}