1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
use super::schema::{GraphDefinition, NodeDefinition};
use super::{EdgeTarget, NodeState, StateGraph};
use anyhow::{anyhow, Context, Result};
pub struct GraphLoader;
impl GraphLoader {
pub fn load_from_str(yaml: &str) -> Result<StateGraph> {
let def: GraphDefinition =
serde_yaml::from_str(yaml).context("Failed to parse graph definition YAML")?;
let mut graph = StateGraph::new();
// 1. Add all nodes
for (name, node_def) in &def.nodes {
match node_def {
NodeDefinition::Llm {
model,
system_prompt,
..
} => {
// Placeholder for LLM node
let name_clone = name.clone();
let model = model.clone().unwrap_or_else(|| "default".to_string());
let prompt = system_prompt.clone();
graph = graph.add_node(name, move |state: NodeState| {
let n = name_clone.clone();
let m = model.clone();
let p = prompt.clone();
async move {
println!("🤖 [LLM Node: {}] Model: {}, Prompt: {:.30}...", n, m, p);
// In a real implementation, this would call LlmCallable
Ok(state)
}
});
}
NodeDefinition::Function { action, .. } => {
let name_clone = name.clone();
let action = action.clone();
graph = graph.add_node(name, move |state: NodeState| {
let n = name_clone.clone();
let a = action.clone();
async move {
println!("⚙️ [Function Node: {}] Action: {}", n, a);
// In a real implementation, this would execute the actionCommand
// For now, allow simple "echo" for testing
if a.starts_with("echo ") {
let output = a.trim_start_matches("echo ").to_string();
return Ok(NodeState::from_str(&output));
}
Ok(state)
}
});
}
NodeDefinition::Condition { expr, .. } => {
let name_clone = name.clone();
let expr = expr.clone();
// Condition node evaluates expression and returns the result key
// (which matches an edge key)
graph = graph.add_node(name, move |state: NodeState| {
let n = name_clone.clone();
let e = expr.clone();
async move {
println!("❓ [Condition Node: {}] Expr: {}", n, e);
// Simple mock evaluation
// If input contains "error", return "error", else "ok"
let input = state.as_str().unwrap_or("");
if e.contains("contains('error')") {
if input.contains("error") {
return Ok(NodeState::from_str("error"));
} else {
return Ok(NodeState::from_str("ok"));
}
}
Ok(NodeState::from_str("default"))
}
});
}
_ => {
return Err(anyhow!("Unsupported node type in yaml"));
}
}
}
// 2. Add edges
for (name, node_def) in &def.nodes {
let edges = node_def.edges();
// Check if this is a conditional node (router)
// If it has multiple edges with keys other than "_default",
// valid keys are the outputs of the previous node.
// For Llm/Function nodes, usually they have a single "_default" edge
// or specific keys if they return structured data?
// The schema implies simple string matching on output.
let is_conditional = matches!(node_def, NodeDefinition::Condition { .. });
if is_conditional {
// Conditional edges based on node output
let edges_clone = edges.clone();
let router = move |output: &str| -> EdgeTarget {
if let Some(target) = edges_clone.get(output) {
if target == "END" {
EdgeTarget::End
} else {
EdgeTarget::Node(target.clone())
}
} else if let Some(default) = edges_clone.get("_default") {
if default == "END" {
EdgeTarget::End
} else {
EdgeTarget::Node(default.clone())
}
} else {
EdgeTarget::End
}
};
graph = graph.add_conditional_edge(name, router);
} else {
// Standard edges
// TODO: Support branching from non-condition nodes?
// For now, assume "_default" is the main edge
if let Some(target) = edges.get("_default") {
if target == "END" {
graph = graph.add_edge_to_end(name);
} else {
graph = graph.add_edge(name, target);
}
}
}
}
// 3. Set entry point
// Ideally schema allows defining it, or we use first node?
// Current StateGraph defaults to first node if not set.
// We could look for "start" or "input" node?
// The implementation_plan example didn't specify entry point explicitly.
// Let's assume the first defined node in YAML (but HashMap is unordered).
// Use "start" or "input" if present, else random?
// Better: require `triggers` or look for a node named "start".
if def.nodes.contains_key("start") {
graph = graph.set_entry_point("start");
} else if def.nodes.contains_key("input") {
graph = graph.set_entry_point("input");
}
Ok(graph)
}
}