Skip to main content

orchestral_runtime/
workflow_strategy.rs

1//! Guarded workflow execution built on the existing Plan/DAG/Executor assets.
2//!
3//! This module does not introduce a second scheduler. It gives the existing
4//! Executor a run-scoped execution port whose only effect path is the same
5//! [`AgentToolRuntime`] used by the Generic Agent loop.
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, OnceLock};
10
11use async_trait::async_trait;
12use orchestral_core::agent_protocol::wire::{Digest, RunId};
13use orchestral_core::executor::{
14    ExecutionDag, ExecutionProgressReporter, ExecutionResult, Executor, ExecutorContext,
15    StepExecutionPort, StepExecutionRequest, StepOutcome,
16};
17use orchestral_core::normalizer::{NormalizeError, PlanNormalizer};
18use orchestral_core::spi::{HookRegistry, RuntimeHookContext, RuntimeHookEventEnvelope, SpiMeta};
19use orchestral_core::tool_effect::{ToolEffectKey, ToolEffectPhase};
20use orchestral_core::tool_protocol::{
21    RunToolGrant, ToolCallId, ToolInvocation, ToolOutcome, ToolOutput,
22};
23use orchestral_core::types::{Plan, StepId, StepKind, WorkflowId};
24use orchestral_core::workflow_state::WorkingSet;
25use serde::Serialize;
26use serde_json::Value;
27use tokio::sync::RwLock;
28use tokio_util::sync::CancellationToken;
29
30use crate::tool_runtime::{AgentToolRuntime, GuardedToolResult};
31
32const DEFAULT_MAX_WORKFLOW_TOOL_CALLS: u64 = 32;
33const WORKFLOW_RECOVERY_CONTRACT_VERSION: &str = "workflow-recovery/v1";
34const WORKFLOW_STEP_CALL_ID_VERSION: &str = "workflow-step-call/v1";
35
36/// One invocation of the workflow execution strategy.
37pub struct WorkflowExecutionRequest {
38    pub run_id: RunId,
39    pub workflow_id: WorkflowId,
40    pub plan: Plan,
41    pub run_grant: RunToolGrant,
42    pub working_set: WorkingSet,
43    pub cancellation: CancellationToken,
44    /// Bridge into the owning Agent Run's live progress stream.
45    pub progress_reporter: Option<Arc<dyn ExecutionProgressReporter>>,
46    /// Optional Run-specific cap. The strategy's Host cap still wins.
47    pub max_tool_calls: Option<u64>,
48    /// Re-enter an already fenced Workflow using only durable Tool outcomes.
49    /// The strategy performs a global unresolved-effect preflight before any
50    /// new Step is dispatched.
51    pub recovery_replay: bool,
52}
53
54impl WorkflowExecutionRequest {
55    pub fn new(
56        run_id: RunId,
57        workflow_id: WorkflowId,
58        plan: Plan,
59        run_grant: RunToolGrant,
60    ) -> Self {
61        Self {
62            run_id,
63            workflow_id,
64            plan,
65            run_grant,
66            working_set: WorkingSet::new(),
67            cancellation: CancellationToken::new(),
68            progress_reporter: None,
69            max_tool_calls: None,
70            recovery_replay: false,
71        }
72    }
73
74    pub fn with_working_set(mut self, working_set: WorkingSet) -> Self {
75        self.working_set = working_set;
76        self
77    }
78
79    pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self {
80        self.cancellation = cancellation;
81        self
82    }
83
84    pub fn with_progress_reporter(
85        mut self,
86        progress_reporter: Arc<dyn ExecutionProgressReporter>,
87    ) -> Self {
88        self.progress_reporter = Some(progress_reporter);
89        self
90    }
91
92    pub fn with_max_tool_calls(mut self, max_tool_calls: u64) -> Self {
93        self.max_tool_calls = Some(max_tool_calls);
94        self
95    }
96
97    pub fn with_recovery_replay(mut self) -> Self {
98        self.recovery_replay = true;
99        self
100    }
101}
102
103/// Result of one normalized DAG execution.
104///
105/// The caller remains the Agent Run owner and decides how to journal or expose
106/// this snapshot; it does not define a separate Run terminal state.
107#[derive(Debug)]
108pub struct WorkflowExecutionSnapshot {
109    pub result: ExecutionResult,
110    pub normalized_plan: Plan,
111    pub dag: ExecutionDag,
112    pub working_set: HashMap<String, Value>,
113    pub normalizer_fixes: Vec<String>,
114    pub tool_calls: u64,
115}
116
117impl WorkflowExecutionSnapshot {
118    /// Stable model-facing observation. It is an internal workflow result,
119    /// never a second Agent Run terminal state.
120    pub fn tool_result(&self) -> (Value, bool) {
121        let mut output = serde_json::Map::from_iter([
122            (
123                "working_set".to_owned(),
124                serde_json::to_value(&self.working_set).unwrap_or(Value::Null),
125            ),
126            (
127                "normalizer_fixes".to_owned(),
128                serde_json::to_value(&self.normalizer_fixes).unwrap_or(Value::Null),
129            ),
130            ("tool_calls".to_owned(), Value::from(self.tool_calls)),
131        ]);
132        let is_error = match &self.result {
133            ExecutionResult::Completed => {
134                output.insert("status".to_owned(), Value::String("completed".to_owned()));
135                false
136            }
137            ExecutionResult::Failed { step_id, error } => {
138                output.insert("status".to_owned(), Value::String("failed".to_owned()));
139                output.insert("step_id".to_owned(), Value::String(step_id.to_string()));
140                output.insert("error".to_owned(), Value::String(error.clone()));
141                true
142            }
143            ExecutionResult::WaitingUser { step_id, prompt } => {
144                output.insert(
145                    "status".to_owned(),
146                    Value::String("waiting_user".to_owned()),
147                );
148                output.insert("step_id".to_owned(), Value::String(step_id.to_string()));
149                output.insert("prompt".to_owned(), Value::String(prompt.clone()));
150                true
151            }
152            ExecutionResult::WaitingEvent {
153                step_id,
154                event_type,
155            } => {
156                output.insert(
157                    "status".to_owned(),
158                    Value::String("waiting_event".to_owned()),
159                );
160                output.insert("step_id".to_owned(), Value::String(step_id.to_string()));
161                output.insert("event_type".to_owned(), Value::String(event_type.clone()));
162                true
163            }
164        };
165        (Value::Object(output), is_error)
166    }
167}
168
169#[derive(Debug, thiserror::Error)]
170#[non_exhaustive]
171pub enum WorkflowExecutionError {
172    #[error("workflow Plan normalization failed: {0}")]
173    Normalize(#[from] NormalizeError),
174    #[error("invalid workflow execution request: {0}")]
175    InvalidRequest(String),
176    #[error("workflow recovery is not supported by this execution contract: {0}")]
177    RecoveryUnsupported(String),
178    #[error("workflow recovery state is inconsistent: {0}")]
179    RecoveryState(String),
180    #[error("workflow recovery is blocked by an unknown Tool effect {call_id}: {message}")]
181    UnknownEffect {
182        call_id: ToolCallId,
183        message: String,
184    },
185    #[error("workflow Tool effect inspection failed: {0}")]
186    EffectInspection(String),
187}
188
189/// Thin strategy around the original normalizer and DAG executor.
190pub struct WorkflowExecutionStrategy {
191    normalizer: Arc<PlanNormalizer>,
192    executor: Arc<Executor>,
193    tools: Arc<dyn AgentToolRuntime>,
194    hooks: Option<Arc<HookRegistry>>,
195    max_tool_calls: u64,
196}
197
198impl WorkflowExecutionStrategy {
199    pub fn new(
200        normalizer: Arc<PlanNormalizer>,
201        executor: Arc<Executor>,
202        tools: Arc<dyn AgentToolRuntime>,
203    ) -> Self {
204        Self {
205            normalizer,
206            executor,
207            tools,
208            hooks: None,
209            max_tool_calls: DEFAULT_MAX_WORKFLOW_TOOL_CALLS,
210        }
211    }
212
213    /// Attaches the stable Runtime Hook SPI to guarded workflow Steps.
214    pub fn with_hooks(mut self, hooks: Arc<HookRegistry>) -> Self {
215        self.hooks = Some(hooks);
216        self
217    }
218
219    pub fn with_max_tool_calls(mut self, max_tool_calls: u64) -> Self {
220        self.max_tool_calls = max_tool_calls;
221        self
222    }
223
224    pub fn uses_tool_runtime(&self, runtime: &Arc<dyn AgentToolRuntime>) -> bool {
225        Arc::ptr_eq(&self.tools, runtime)
226    }
227
228    pub(crate) fn recovery_contract(&self) -> serde_json::Value {
229        serde_json::json!({
230            "version": WORKFLOW_RECOVERY_CONTRACT_VERSION,
231            "step_call_identity": WORKFLOW_STEP_CALL_ID_VERSION,
232            "max_tool_calls": self.max_tool_calls,
233            "hooks_enabled": self.hooks.is_some(),
234            "normalizer": self.normalizer.deterministic_contract(),
235            "executor": {
236                "max_parallel": self.executor.max_parallel,
237                "max_retry_attempts": self.executor.max_retry_attempts,
238                "retry_base_delay_nanos": self.executor.retry_base_delay.as_nanos().to_string(),
239                "retry_max_delay_nanos": self.executor.retry_max_delay.as_nanos().to_string(),
240                "strict_exports": self.executor.strict_exports,
241            },
242        })
243    }
244
245    pub(crate) fn supports_recovery_replay(&self) -> bool {
246        self.hooks.is_none() && self.normalizer.deterministic_contract().is_some()
247    }
248
249    pub async fn execute(
250        &self,
251        request: WorkflowExecutionRequest,
252    ) -> Result<WorkflowExecutionSnapshot, WorkflowExecutionError> {
253        if request.run_id.is_empty() || request.workflow_id.as_str().trim().is_empty() {
254            return Err(WorkflowExecutionError::InvalidRequest(
255                "run_id and workflow_id must not be empty".to_owned(),
256            ));
257        }
258        let progress_reporter = request.progress_reporter.clone().ok_or_else(|| {
259            WorkflowExecutionError::InvalidRequest(
260                "workflow execution must project progress into its owning Agent Run".to_owned(),
261            )
262        })?;
263        if request
264            .plan
265            .steps
266            .iter()
267            .any(|step| !matches!(step.kind, StepKind::Action | StepKind::System))
268        {
269            return Err(WorkflowExecutionError::InvalidRequest(
270                "Generic workflow currently accepts only Action and System steps".to_owned(),
271            ));
272        }
273        request
274            .run_grant
275            .bounds
276            .validate()
277            .map_err(|error| WorkflowExecutionError::InvalidRequest(error.message))?;
278        if self.max_tool_calls == 0 || request.max_tool_calls == Some(0) {
279            return Err(WorkflowExecutionError::InvalidRequest(
280                "Tool call limits must be positive".to_owned(),
281            ));
282        }
283
284        let effective_tool_limit = request
285            .max_tool_calls
286            .unwrap_or(self.max_tool_calls)
287            .min(self.max_tool_calls);
288        let normalized = self.normalizer.normalize(request.plan)?;
289        let plan_digest = workflow_plan_digest(&normalized.plan)?;
290        let mut dag = normalized.dag;
291        if request.recovery_replay {
292            if !self.supports_recovery_replay() {
293                return Err(WorkflowExecutionError::RecoveryUnsupported(
294                    "custom normalizer rules or lifecycle hooks have no durable replay identity"
295                        .to_owned(),
296                ));
297            }
298            self.preflight_recovery_effects(
299                &request.run_id,
300                &request.workflow_id,
301                &normalized.plan,
302                &plan_digest,
303            )
304            .await?;
305        }
306        let working_set = Arc::new(RwLock::new(request.working_set));
307        let port = Arc::new(RunBoundGuardedToolPort::new(
308            request.run_id,
309            request.run_grant,
310            self.tools.clone(),
311            self.hooks.clone(),
312            effective_tool_limit,
313            plan_digest,
314        ));
315        let context = ExecutorContext::new(request.workflow_id, working_set.clone(), port.clone())
316            .with_cancellation_token(request.cancellation)
317            .with_progress_reporter(progress_reporter);
318        // When the budget could bind, serialize the otherwise sorted ready
319        // frontier so the accepted logical calls do not depend on task races.
320        let maximum_logical_calls = (normalized.plan.steps.len() as u64)
321            .saturating_mul(u64::from(self.executor.max_retry_attempts).saturating_add(1));
322        let max_parallel = if effective_tool_limit < maximum_logical_calls {
323            1
324        } else {
325            self.executor.max_parallel
326        };
327        let executor = Executor {
328            max_parallel,
329            max_retry_attempts: self.executor.max_retry_attempts,
330            retry_base_delay: self.executor.retry_base_delay,
331            retry_max_delay: self.executor.retry_max_delay,
332            strict_exports: self.executor.strict_exports,
333        };
334        let result = executor.execute(&mut dag, &context).await;
335        if let Some(message) = port.unknown_effect() {
336            return Err(WorkflowExecutionError::UnknownEffect {
337                call_id: port
338                    .unknown_call_id()
339                    .expect("unknown effect always records its call identity"),
340                message,
341            });
342        }
343        let working_set = working_set.read().await.export_workflow_data();
344
345        Ok(WorkflowExecutionSnapshot {
346            result,
347            normalized_plan: normalized.plan,
348            dag,
349            working_set,
350            normalizer_fixes: normalized.fix_summary,
351            tool_calls: port.tool_calls(),
352        })
353    }
354
355    async fn preflight_recovery_effects(
356        &self,
357        run_id: &RunId,
358        workflow_id: &WorkflowId,
359        plan: &Plan,
360        plan_digest: &Digest,
361    ) -> Result<(), WorkflowExecutionError> {
362        let mut step_ids = plan
363            .steps
364            .iter()
365            .map(|step| step.id.clone())
366            .collect::<Vec<_>>();
367        step_ids.sort_by(|left, right| left.as_str().cmp(right.as_str()));
368        for step_id in step_ids {
369            let mut prior_attempt_allows_retry = true;
370            let mut saw_gap = false;
371            for attempt in 1..=self.executor.max_retry_attempts.saturating_add(1) {
372                let call_id =
373                    workflow_step_call_id(run_id, workflow_id, plan_digest, &step_id, attempt);
374                let key = ToolEffectKey::new(run_id.clone(), call_id.clone());
375                let projection =
376                    self.tools.inspect_effect(&key).await.map_err(|error| {
377                        WorkflowExecutionError::EffectInspection(error.to_string())
378                    })?;
379                let Some(projection) = projection else {
380                    saw_gap = true;
381                    continue;
382                };
383                if saw_gap || !prior_attempt_allows_retry {
384                    return Err(WorkflowExecutionError::RecoveryState(format!(
385                        "Step {} has a non-contiguous durable retry attempt {}",
386                        step_id, attempt
387                    )));
388                }
389                prior_attempt_allows_retry = match projection.phase {
390                    ToolEffectPhase::Invoked { .. } => {
391                        return Err(WorkflowExecutionError::UnknownEffect {
392                            call_id,
393                            message: "durable invocation has no observation".to_owned(),
394                        })
395                    }
396                    ToolEffectPhase::UnknownEffect { reason, .. } => {
397                        return Err(WorkflowExecutionError::UnknownEffect {
398                            call_id,
399                            message: reason,
400                        })
401                    }
402                    ToolEffectPhase::Prepared => false,
403                    ToolEffectPhase::Observed { outcome, .. }
404                    | ToolEffectPhase::Committed { outcome, .. } => matches!(
405                        outcome,
406                        ToolOutcome::Failed {
407                            retryable: true,
408                            ..
409                        }
410                    ),
411                };
412            }
413        }
414        Ok(())
415    }
416}
417
418#[derive(Serialize)]
419struct WorkflowStepCallIdentity<'a> {
420    version: &'static str,
421    run_id: &'a str,
422    workflow_id: &'a str,
423    plan_digest: &'a str,
424    step_id: &'a str,
425    attempt: u32,
426}
427
428/// Stable Tool call identity for one logical Workflow Step attempt.
429pub fn workflow_step_call_id(
430    run_id: &RunId,
431    workflow_id: &WorkflowId,
432    plan_digest: &Digest,
433    step_id: &StepId,
434    attempt: u32,
435) -> ToolCallId {
436    let bytes = serde_jcs::to_vec(&WorkflowStepCallIdentity {
437        version: WORKFLOW_STEP_CALL_ID_VERSION,
438        run_id: run_id.as_str(),
439        workflow_id: workflow_id.as_str(),
440        plan_digest: plan_digest.as_str(),
441        step_id: step_id.as_str(),
442        attempt,
443    })
444    .expect("Workflow Step call identity contains only finite scalar values");
445    ToolCallId::new(format!("workflow-step:{}", Digest::sha256(bytes).as_str()))
446}
447
448/// Canonical identity of the normalized Plan bound to all Step call IDs.
449pub fn workflow_plan_digest(plan: &Plan) -> Result<Digest, WorkflowExecutionError> {
450    serde_jcs::to_vec(plan)
451        .map(Digest::sha256)
452        .map_err(|error| WorkflowExecutionError::InvalidRequest(error.to_string()))
453}
454
455/// Run-bound adapter from normalized DAG Steps to the guarded Tool boundary.
456///
457/// It deliberately has no `ActionRegistry` reference, so a missing or denied
458/// Tool cannot fall back to legacy execution.
459pub struct RunBoundGuardedToolPort {
460    run_id: RunId,
461    run_grant: RunToolGrant,
462    tools: Arc<dyn AgentToolRuntime>,
463    hooks: Option<Arc<HookRegistry>>,
464    max_tool_calls: u64,
465    plan_digest: Digest,
466    tool_calls: AtomicU64,
467    unknown_effect: OnceLock<(ToolCallId, String)>,
468}
469
470impl RunBoundGuardedToolPort {
471    fn new(
472        run_id: RunId,
473        run_grant: RunToolGrant,
474        tools: Arc<dyn AgentToolRuntime>,
475        hooks: Option<Arc<HookRegistry>>,
476        max_tool_calls: u64,
477        plan_digest: Digest,
478    ) -> Self {
479        Self {
480            run_id,
481            run_grant,
482            tools,
483            hooks,
484            max_tool_calls,
485            plan_digest,
486            tool_calls: AtomicU64::new(0),
487            unknown_effect: OnceLock::new(),
488        }
489    }
490
491    pub fn tool_calls(&self) -> u64 {
492        self.tool_calls.load(Ordering::Acquire)
493    }
494
495    fn reserve_tool_call(&self) -> bool {
496        self.tool_calls
497            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
498                (current < self.max_tool_calls).then_some(current + 1)
499            })
500            .is_ok()
501    }
502
503    fn unknown_effect(&self) -> Option<String> {
504        self.unknown_effect
505            .get()
506            .map(|(_, message)| message.clone())
507    }
508
509    fn unknown_call_id(&self) -> Option<ToolCallId> {
510        self.unknown_effect
511            .get()
512            .map(|(call_id, _)| call_id.clone())
513    }
514}
515
516#[async_trait]
517impl StepExecutionPort for RunBoundGuardedToolPort {
518    async fn execute_step(
519        &self,
520        request: StepExecutionRequest,
521        context: &ExecutorContext,
522    ) -> StepOutcome {
523        if let Err(error) = self
524            .dispatch_step_hook(
525                "before_step",
526                &request,
527                context,
528                serde_json::json!({ "phase": "before" }),
529            )
530            .await
531        {
532            let result = StepOutcome::error(format!("before_step hook rejected Step: {error}"));
533            return self.dispatch_step_error(&request, context, result).await;
534        }
535
536        let mut result = self.execute_guarded_step(&request, context).await;
537        if self.unknown_effect.get().is_some() {
538            return result;
539        }
540        if let Err(error) = self
541            .dispatch_step_hook(
542                "after_step",
543                &request,
544                context,
545                serde_json::json!({
546                    "phase": "after",
547                    "result": result,
548                }),
549            )
550            .await
551        {
552            result = StepOutcome::error(format!("after_step hook rejected Step: {error}"));
553        }
554        if matches!(
555            result,
556            StepOutcome::Error { .. } | StepOutcome::RetryableError { .. }
557        ) {
558            result = self.dispatch_step_error(&request, context, result).await;
559        }
560        result
561    }
562}
563
564impl RunBoundGuardedToolPort {
565    async fn execute_guarded_step(
566        &self,
567        request: &StepExecutionRequest,
568        context: &ExecutorContext,
569    ) -> StepOutcome {
570        if !matches!(request.step_kind, StepKind::Action | StepKind::System) {
571            return StepOutcome::error(format!(
572                "guarded Workflow does not execute {:?} steps through an Action fallback",
573                request.step_kind
574            ));
575        }
576        if !self.reserve_tool_call() {
577            return StepOutcome::error("workflow Tool call limit reached");
578        }
579        let tool_id = match self.tools.resolve_tool_id(&request.action) {
580            Ok(Some(tool_id)) => tool_id,
581            Ok(None) => {
582                return StepOutcome::error(format!(
583                    "workflow Tool is not registered: {}",
584                    request.action
585                ))
586            }
587            Err(error) => {
588                return StepOutcome::retryable(
589                    format!("Tool Runtime unavailable: {error}"),
590                    None,
591                    0,
592                )
593            }
594        };
595        let call_id = workflow_step_call_id(
596            &self.run_id,
597            &context.workflow_id,
598            &self.plan_digest,
599            &request.step_id,
600            request.attempt,
601        );
602        let result = self
603            .tools
604            .invoke(
605                ToolInvocation {
606                    run_id: self.run_id.clone(),
607                    call_id: call_id.clone(),
608                    tool_id,
609                    arguments: request.resolved_params.clone(),
610                },
611                self.run_grant.clone(),
612                None,
613                context.cancellation_token.clone(),
614            )
615            .await;
616        match result {
617            GuardedToolResult::ApprovalRequired { .. } => StepOutcome::error(
618                "Tool approval is required, but Workflow approval interaction is not connected",
619            ),
620            GuardedToolResult::Outcome {
621                outcome: ToolOutcome::Completed { output },
622                ..
623            } => match output {
624                ToolOutput::Inline(Value::Object(exports)) => {
625                    StepOutcome::success_with(exports.into_iter().collect())
626                }
627                ToolOutput::Artifact(artifact) => StepOutcome::error(format!(
628                    "workflow Tool output was spilled to Artifact {}; it cannot directly satisfy Step exports",
629                    artifact.artifact.artifact_ref
630                )),
631                _ => StepOutcome::error(
632                    "workflow Tool output must be an object so it can satisfy Step exports",
633                ),
634            },
635            GuardedToolResult::Outcome {
636                outcome: ToolOutcome::Rejected { code, message },
637                ..
638            } => StepOutcome::error(format!("Tool rejected [{code}]: {message}")),
639            GuardedToolResult::Outcome {
640                outcome:
641                    ToolOutcome::Failed {
642                        code,
643                        message,
644                        retryable,
645                    },
646                ..
647            } if retryable => {
648                StepOutcome::retryable(format!("Tool failed [{code}]: {message}"), None, 0)
649            }
650            GuardedToolResult::Outcome {
651                outcome: ToolOutcome::Failed { code, message, .. },
652                ..
653            } => StepOutcome::error(format!("Tool failed [{code}]: {message}")),
654            GuardedToolResult::Outcome {
655                outcome: ToolOutcome::Cancelled,
656                ..
657            } => StepOutcome::error("workflow Tool execution cancelled"),
658            GuardedToolResult::Outcome {
659                outcome: ToolOutcome::UnknownEffect { message },
660                ..
661            } => {
662                let _ = self.unknown_effect.set((call_id, message.clone()));
663                StepOutcome::error(format!("workflow Tool effect is unknown: {message}"))
664            }
665            GuardedToolResult::Outcome { .. } => {
666                StepOutcome::error("unsupported Tool outcome returned by Tool Runtime")
667            }
668        }
669    }
670
671    async fn dispatch_step_error(
672        &self,
673        request: &StepExecutionRequest,
674        context: &ExecutorContext,
675        result: StepOutcome,
676    ) -> StepOutcome {
677        let payload = serde_json::json!({
678            "phase": "error",
679            "result": result,
680        });
681        match self
682            .dispatch_step_hook("on_step_error", request, context, payload)
683            .await
684        {
685            Ok(()) => result,
686            Err(error) => StepOutcome::error(format!(
687                "{}; on_step_error hook rejected Step: {error}",
688                step_error_message(&result)
689            )),
690        }
691    }
692
693    async fn dispatch_step_hook(
694        &self,
695        event_type: &str,
696        request: &StepExecutionRequest,
697        context: &ExecutorContext,
698        payload: Value,
699    ) -> Result<(), String> {
700        let Some(hooks) = &self.hooks else {
701            return Ok(());
702        };
703        hooks
704            .dispatch_checked(
705                &RuntimeHookEventEnvelope {
706                    meta: SpiMeta::runtime_defaults(env!("CARGO_PKG_VERSION")),
707                    event_type: event_type.to_owned(),
708                    event_version: "1.0.0".to_owned(),
709                    occurred_at_unix_ms: chrono::Utc::now().timestamp_millis(),
710                    payload: serde_json::json!({
711                        "run_id": self.run_id.as_str(),
712                        "workflow_id": context.workflow_id.as_str(),
713                        "step_id": request.step_id.as_str(),
714                        "execution_id": request.execution_id,
715                        "attempt": request.attempt,
716                        "action": request.action,
717                        "detail": payload,
718                    }),
719                    extensions: serde_json::Map::new(),
720                },
721                &RuntimeHookContext {
722                    session_id: None,
723                    run_id: Some(self.run_id.clone()),
724                    workflow_id: Some(context.workflow_id.clone()),
725                    step_id: Some(request.step_id.clone()),
726                    tool_name: Some(request.action.clone()),
727                    message: None,
728                    metadata: serde_json::json!({ "run_id": self.run_id.as_str() }),
729                    extensions: serde_json::Map::new(),
730                },
731            )
732            .await
733            .map_err(|error| error.to_string())
734    }
735}
736
737fn step_error_message(result: &StepOutcome) -> &str {
738    match result {
739        StepOutcome::Error { message } | StepOutcome::RetryableError { message, .. } => message,
740        _ => "Step failed",
741    }
742}