runifold_workflow/
step.rs1use std::{fmt, future::Future, pin::Pin, sync::Arc};
2
3use runifold_agent::{Agent, AgentOutcome};
4use runifold_core::RunContext;
5use runifold_model::ContentPart;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::WorkflowStepError;
10
11#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
13#[serde(transparent)]
14pub struct StepId(String);
15
16impl StepId {
17 pub(crate) fn parse(value: impl Into<String>) -> Result<Self, String> {
18 let value = value.into();
19 let valid = !value.is_empty()
20 && value.len() <= 128
21 && value
22 .bytes()
23 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
24 valid.then_some(Self(value.clone())).ok_or(value)
25 }
26
27 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31}
32
33impl fmt::Display for StepId {
34 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35 self.0.fmt(formatter)
36 }
37}
38
39#[cfg(not(target_arch = "wasm32"))]
41pub type WorkflowStepFuture<'a> =
42 Pin<Box<dyn Future<Output = Result<Value, WorkflowStepError>> + Send + 'a>>;
43
44#[cfg(target_arch = "wasm32")]
46pub type WorkflowStepFuture<'a> =
47 Pin<Box<dyn Future<Output = Result<Value, WorkflowStepError>> + 'a>>;
48
49pub trait WorkflowStep: Send + Sync {
55 fn execute<'a>(&'a self, input: Value, run: &'a RunContext) -> WorkflowStepFuture<'a>;
57}
58
59pub trait WorkflowCondition: Send + Sync {
61 fn evaluate(&self, input: &Value) -> Result<bool, WorkflowStepError>;
68}
69
70pub struct PredicateCondition<F> {
72 predicate: F,
73}
74
75impl<F> PredicateCondition<F> {
76 pub const fn new(predicate: F) -> Self {
78 Self { predicate }
79 }
80}
81
82impl<F> WorkflowCondition for PredicateCondition<F>
83where
84 F: Fn(&Value) -> Result<bool, WorkflowStepError> + Send + Sync,
85{
86 fn evaluate(&self, input: &Value) -> Result<bool, WorkflowStepError> {
87 (self.predicate)(input)
88 }
89}
90
91impl<F> fmt::Debug for PredicateCondition<F> {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter
94 .debug_struct("PredicateCondition")
95 .finish_non_exhaustive()
96 }
97}
98
99#[derive(Clone, Debug)]
101pub struct AgentStep {
102 agent: Arc<Agent>,
103}
104
105impl AgentStep {
106 pub const fn new(agent: Arc<Agent>) -> Self {
108 Self { agent }
109 }
110
111 pub const fn agent(&self) -> &Arc<Agent> {
113 &self.agent
114 }
115}
116
117#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
122pub struct AgentStepOutput {
123 pub input: String,
125 pub outcome: AgentOutcome,
127}
128
129impl WorkflowStep for AgentStep {
130 fn execute<'a>(&'a self, input: Value, run: &'a RunContext) -> WorkflowStepFuture<'a> {
131 Box::pin(async move {
132 let prompt = match input {
133 Value::String(prompt) => prompt,
134 Value::Object(mut object) => object
135 .remove("input")
136 .and_then(|value| value.as_str().map(ToOwned::to_owned))
137 .ok_or_else(|| {
138 WorkflowStepError::InvalidInput(
139 "Agent steps require a string or an object containing string `input`"
140 .into(),
141 )
142 })?,
143 _ => {
144 return Err(WorkflowStepError::InvalidInput(
145 "Agent steps require a string or an object containing string `input`"
146 .into(),
147 ));
148 }
149 };
150 let outcome = self.agent.run(prompt, run).await?;
151 let input = agent_text(&outcome)?;
152 Ok(serde_json::to_value(AgentStepOutput { input, outcome })?)
153 })
154 }
155}
156
157fn agent_text(outcome: &AgentOutcome) -> Result<String, WorkflowStepError> {
158 if outcome
159 .response
160 .content
161 .iter()
162 .any(|part| matches!(part, ContentPart::Refusal { .. }))
163 {
164 return Err(WorkflowStepError::InvalidOutput(
165 "Agent returned a refusal that cannot be forwarded automatically".into(),
166 ));
167 }
168 let text = outcome
169 .response
170 .content
171 .iter()
172 .filter_map(|part| match part {
173 ContentPart::Text { text } => Some(text.as_str()),
174 _ => None,
175 })
176 .collect::<String>();
177 if text.is_empty() {
178 return Err(WorkflowStepError::InvalidOutput(
179 "Agent returned no terminal text to forward".into(),
180 ));
181 }
182 Ok(text)
183}