Skip to main content

simple_agents_workflow/yaml_runner/
contracts.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use simple_agent_type::message::Role;
7use simple_agent_type::tool::{ToolChoice, ToolType};
8use thiserror::Error;
9
10use super::{TraceContext, YamlToolTraceMode, YamlWorkflowTraceTenantContext};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum YamlWorkflowTokenKind {
15    Output,
16    Thinking,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize)]
20pub struct YamlWorkflowEvent {
21    pub event_type: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub node_id: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub step_id: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub node_kind: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub streamable: Option<bool>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub message: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub delta: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub token_kind: Option<YamlWorkflowTokenKind>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub is_terminal_node_token: Option<bool>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub elapsed_ms: Option<u128>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub metadata: Option<Value>,
42}
43
44pub type WorkflowMessageRole = Role;
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct WorkflowMessage {
48    pub role: WorkflowMessageRole,
49    pub content: String,
50    #[serde(default)]
51    pub name: Option<String>,
52    #[serde(default, alias = "toolCallId")]
53    pub tool_call_id: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize)]
57pub struct YamlTemplateBinding {
58    pub index: usize,
59    pub expression: String,
60    pub source_path: String,
61    pub resolved: Value,
62    pub resolved_type: String,
63    pub missing: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub enum YamlWorkflowDiagnosticSeverity {
68    Error,
69    Warning,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct YamlWorkflowDiagnostic {
74    pub node_id: Option<String>,
75    pub code: String,
76    pub severity: YamlWorkflowDiagnosticSeverity,
77    pub message: String,
78}
79
80#[derive(Debug, Error)]
81pub enum YamlWorkflowRunError {
82    #[error("failed to read workflow yaml '{path}': {source}")]
83    Read {
84        path: String,
85        source: std::io::Error,
86    },
87    #[error("failed to parse workflow yaml '{path}': {source}")]
88    Parse {
89        path: String,
90        source: serde_yaml::Error,
91    },
92    #[error("rejected workflow yaml '{path}': {reason}")]
93    FileRejected { path: String, reason: String },
94    #[error("workflow '{workflow_id}' has no nodes")]
95    EmptyNodes { workflow_id: String },
96    #[error("entry node '{entry_node}' does not exist")]
97    MissingEntry { entry_node: String },
98    #[error("unknown node id '{node_id}'")]
99    MissingNode { node_id: String },
100    #[error("unsupported node type in '{node_id}'")]
101    UnsupportedNodeType { node_id: String },
102    #[error("unsupported switch condition format: {condition}")]
103    UnsupportedCondition { condition: String },
104    #[error("switch node '{node_id}' has no valid next target")]
105    InvalidSwitchTarget { node_id: String },
106    #[error("llm returned non-object payload for node '{node_id}'")]
107    LlmPayloadNotObject { node_id: String },
108    #[error("custom worker handler '{handler}' is not supported")]
109    UnsupportedCustomHandler { handler: String },
110    #[error("llm execution failed for node '{node_id}': {message}")]
111    Llm { node_id: String, message: String },
112    #[error("custom worker execution failed for node '{node_id}': {message}")]
113    CustomWorker { node_id: String, message: String },
114    #[error("workflow validation failed with {diagnostics_count} error(s)")]
115    Validation {
116        diagnostics_count: usize,
117        diagnostics: Vec<YamlWorkflowDiagnostic>,
118    },
119    #[error("invalid workflow input: {message}")]
120    InvalidInput { message: String },
121    #[error("ir runtime execution failed: {message}")]
122    IrRuntime { message: String },
123    #[error("workflow event stream cancelled: {message}")]
124    EventSinkCancelled { message: String },
125}
126
127pub trait YamlWorkflowEventSink: Send + Sync {
128    fn emit(&self, event: &YamlWorkflowEvent);
129
130    fn is_cancelled(&self) -> bool {
131        false
132    }
133}
134
135pub struct NoopYamlWorkflowEventSink;
136
137impl YamlWorkflowEventSink for NoopYamlWorkflowEventSink {
138    fn emit(&self, _event: &YamlWorkflowEvent) {}
139}
140
141pub(super) fn workflow_event_sink_cancelled_message() -> &'static str {
142    "workflow event callback cancelled"
143}
144
145pub(super) fn event_sink_is_cancelled(event_sink: Option<&dyn YamlWorkflowEventSink>) -> bool {
146    event_sink.map(|sink| sink.is_cancelled()).unwrap_or(false)
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Error)]
150pub enum YamlToIrError {
151    #[error("entry node '{entry_node}' does not exist")]
152    MissingEntry { entry_node: String },
153    #[error("node '{node_id}' has multiple outgoing edges in YAML; IR llm/tool nodes require one")]
154    MultipleOutgoingEdge { node_id: String },
155    #[error("node '{node_id}' is unsupported for IR conversion: {reason}")]
156    UnsupportedNode { node_id: String, reason: String },
157}
158
159#[derive(Debug, Clone)]
160pub struct YamlLlmExecutionRequest {
161    pub node_id: String,
162    pub is_terminal_node: bool,
163    pub stream_json_as_text: bool,
164    pub model: String,
165    pub max_tokens: Option<u32>,
166    pub temperature: Option<f32>,
167    pub top_p: Option<f32>,
168    pub messages: Option<Vec<super::Message>>,
169    pub append_prompt_as_user: bool,
170    pub prompt: String,
171    pub prompt_template: String,
172    pub prompt_bindings: Vec<YamlTemplateBinding>,
173    pub schema: Value,
174    pub stream: bool,
175    pub heal: bool,
176    pub tools: Vec<YamlResolvedTool>,
177    pub tool_choice: Option<ToolChoice>,
178    pub max_tool_roundtrips: u8,
179    pub tool_calls_global_key: Option<String>,
180    pub tool_trace_mode: YamlToolTraceMode,
181    pub execution_context: Value,
182    pub email_text: String,
183    pub trace_id: Option<String>,
184    pub trace_context: Option<TraceContext>,
185    pub tenant_context: YamlWorkflowTraceTenantContext,
186    pub trace_sampled: bool,
187}
188
189#[derive(Debug, Clone)]
190pub struct YamlResolvedTool {
191    pub definition: super::ToolDefinition,
192    pub output_schema: Option<Value>,
193}
194
195#[async_trait]
196pub trait YamlWorkflowLlmExecutor: Send + Sync {
197    async fn complete_structured(
198        &self,
199        request: YamlLlmExecutionRequest,
200        event_sink: Option<&dyn YamlWorkflowEventSink>,
201    ) -> Result<super::YamlLlmExecutionResult, String>;
202}
203
204#[async_trait]
205pub trait YamlWorkflowCustomWorkerExecutor: Send + Sync {
206    async fn execute(
207        &self,
208        handler: &str,
209        handler_file: Option<&str>,
210        payload: &Value,
211        email_text: &str,
212        context: &Value,
213    ) -> Result<Value, String>;
214}
215
216#[derive(Debug, Clone, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct YamlGlobalUpdate {
219    pub op: String,
220    pub from: Option<String>,
221    pub by: Option<f64>,
222}
223
224#[derive(Debug, Clone, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct YamlNodeConfig {
227    pub prompt: Option<String>,
228    #[serde(default, alias = "schema")]
229    pub output_schema: Option<Value>,
230    pub payload: Option<Value>,
231    pub set_globals: Option<HashMap<String, String>>,
232    pub update_globals: Option<HashMap<String, YamlGlobalUpdate>>,
233}
234
235#[derive(Debug, Clone, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct YamlCustomWorker {
238    pub handler: String,
239    pub handler_file: Option<String>,
240}
241
242#[derive(Debug, Clone, Deserialize)]
243#[serde(deny_unknown_fields)]
244pub struct YamlSwitchBranch {
245    pub condition: String,
246    pub target: String,
247}
248
249#[derive(Debug, Clone, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct YamlSwitch {
252    #[serde(default)]
253    pub branches: Vec<YamlSwitchBranch>,
254    pub default: String,
255}
256
257#[derive(Debug, Clone, Deserialize)]
258#[serde(untagged)]
259pub enum YamlToolChoiceConfig {
260    Mode(super::ToolChoiceMode),
261    Function { function: String },
262    OpenAi(super::ToolChoiceTool),
263}
264
265#[derive(Debug, Clone, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct YamlSimplifiedToolDeclaration {
268    pub name: String,
269    pub description: Option<String>,
270    pub input_schema: Value,
271    pub output_schema: Option<Value>,
272}
273
274#[derive(Debug, Clone, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct YamlOpenAiToolFunction {
277    pub name: String,
278    pub description: Option<String>,
279    pub parameters: Option<Value>,
280    pub output_schema: Option<Value>,
281}
282
283#[derive(Debug, Clone, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct YamlOpenAiToolDeclaration {
286    #[serde(rename = "type")]
287    pub tool_type: Option<ToolType>,
288    pub function: YamlOpenAiToolFunction,
289}
290
291#[derive(Debug, Clone, Deserialize)]
292#[serde(untagged)]
293pub enum YamlToolDeclaration {
294    OpenAi(YamlOpenAiToolDeclaration),
295    Simplified(YamlSimplifiedToolDeclaration),
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
299#[serde(rename_all = "snake_case")]
300pub enum YamlToolFormat {
301    #[default]
302    Openai,
303    Simplified,
304}
305
306#[derive(Debug, Clone, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct YamlLlmCall {
309    pub model: String,
310    pub max_tokens: Option<u32>,
311    pub temperature: Option<f32>,
312    pub top_p: Option<f32>,
313    pub stream: Option<bool>,
314    pub stream_json_as_text: Option<bool>,
315    pub heal: Option<bool>,
316    pub messages_path: Option<String>,
317    pub append_prompt_as_user: Option<bool>,
318    #[serde(default)]
319    pub tools_format: YamlToolFormat,
320    #[serde(default)]
321    pub tools: Vec<YamlToolDeclaration>,
322    pub tool_choice: Option<YamlToolChoiceConfig>,
323    pub max_tool_roundtrips: Option<u8>,
324    pub tool_calls_global_key: Option<String>,
325}
326
327#[derive(Debug, Clone, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct YamlNodeType {
330    pub llm_call: Option<YamlLlmCall>,
331    pub switch: Option<YamlSwitch>,
332    pub custom_worker: Option<YamlCustomWorker>,
333    pub end: Option<Value>,
334}
335
336#[derive(Debug, Clone, Deserialize)]
337pub struct YamlNode {
338    pub id: String,
339    pub node_type: YamlNodeType,
340    pub config: Option<YamlNodeConfig>,
341}
342
343impl YamlNode {
344    pub(super) fn kind_name(&self) -> &'static str {
345        if self.node_type.llm_call.is_some() {
346            "llm_call"
347        } else if self.node_type.switch.is_some() {
348            "switch"
349        } else if self.node_type.custom_worker.is_some() {
350            "custom_worker"
351        } else if self.node_type.end.is_some() {
352            "end"
353        } else {
354            "unknown"
355        }
356    }
357}
358
359#[derive(Debug, Clone, Deserialize)]
360pub struct YamlEdge {
361    pub from: String,
362    pub to: String,
363}
364
365#[derive(Debug, Clone, Deserialize)]
366pub struct YamlWorkflow {
367    pub id: String,
368    pub entry_node: String,
369    #[serde(default)]
370    pub nodes: Vec<YamlNode>,
371    #[serde(default)]
372    pub edges: Vec<YamlEdge>,
373}