enact-core 0.0.1

Core agent runtime for Enact - Graph-Native AI agents
Documentation
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)
    }
}