1use 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
36pub 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 pub progress_reporter: Option<Arc<dyn ExecutionProgressReporter>>,
46 pub max_tool_calls: Option<u64>,
48 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#[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 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
189pub 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 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 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
428pub 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
448pub 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
455pub 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}