use super::state::{is_lone_template, template_root_keys}; use super::types::{ConcurrencyCap, Graph, NextTargets, Node, NodeType}; use crate::client::{Model, ModelType}; use crate::config; use crate::config::{Agent, AppConfig, paths}; use crate::function::todo::TODO_FUNCTION_PREFIX; use crate::function::user_interaction::USER_FUNCTION_PREFIX; use crate::rag::{GraphRagConfig, RagData}; use anyhow::{Result, bail}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Semaphore; #[derive(Debug, Clone)] pub struct ValidationError { pub node_id: Option, pub message: String, } impl ValidationError { fn new(message: impl Into) -> Self { Self { node_id: None, message: message.into(), } } fn with_node(node_id: impl Into, message: impl Into) -> Self { Self { node_id: Some(node_id.into()), message: message.into(), } } } #[derive(Debug, Default)] pub struct ValidationResult { pub errors: Vec, pub warnings: Vec, } impl ValidationResult { pub fn is_valid(&self) -> bool { self.errors.is_empty() } fn error(&mut self, e: ValidationError) { self.errors.push(e); } fn warning(&mut self, w: ValidationError) { self.warnings.push(w); } pub fn into_result(self) -> Result<()> { if self.is_valid() { return Ok(()); } let lines: Vec = self .errors .iter() .map(|e| match &e.node_id { Some(id) => format!(" [{id}] {}", e.message), None => format!(" {}", e.message), }) .collect(); bail!( "Graph validation failed with {} error(s):\n{}", self.errors.len(), lines.join("\n") ); } } pub struct AgentValidationContext { pub tool_names: HashSet, pub mcp_servers: HashSet, pub app_config: Arc, } impl AgentValidationContext { pub fn from_agent(agent: &Agent, app_config: Arc) -> Self { Self { tool_names: agent .functions() .declarations() .iter() .map(|d| d.name.clone()) .collect(), mcp_servers: agent.mcp_server_names().iter().cloned().collect(), app_config, } } } pub struct GraphValidator { base_dir: PathBuf, agent_ctx: Option, skill_exists: fn(&str) -> bool, } /// A minimal `RagData` whose only interesting field is `driver`. The numeric /// arguments are the smallest values that satisfy `validate()`'s unrelated /// floors (top_k >= 1, and chunk_size >= 1 with chunk_overlap < chunk_size for /// a non-attached RAG). `RagData::new` sets `attached: false`, which is the /// correct shape here: a graph rag node always builds its own local knowledge /// base from `documents` and can never be attached. fn rag_driver_probe(driver: &str) -> RagData { let mut data = RagData::new( String::new(), 1, 0, None, 1, None, GraphRagConfig::default(), ); data.driver = driver.to_string(); data } /// `Some(message)` when `driver` is one that `RagData::validate()` would reject. /// /// The set of valid drivers is defined in exactly one place, `RagData::validate()`, /// so this asks that function rather than restating the list here. /// /// Fails open on purpose: the first probe below uses the default driver, which is /// valid by definition. If even that one is rejected, `validate()` has grown a /// precondition the probe fixture no longer satisfies, and every verdict from here /// would be a false positive that rejects working graphs. In that case we decline /// to judge and leave enforcement to RAG construction. The /// `rag_driver_probe_fixture_is_accepted` test turns that silent degradation into a /// loud failure. Both `validate()` calls are load-bearing; neither is redundant. pub(crate) fn rag_driver_error(driver: &str) -> Option { if rag_driver_probe(&RagData::default().driver) .validate() .is_err() { return None; } rag_driver_probe(driver) .validate() .err() .map(|err| err.to_string()) } impl GraphValidator { pub fn new(base_dir: impl Into) -> Self { Self { base_dir: base_dir.into(), agent_ctx: None, skill_exists: paths::has_skill, } } pub fn with_agent_context(mut self, ctx: AgentValidationContext) -> Self { self.agent_ctx = Some(ctx); self } #[cfg(test)] pub fn with_skill_exists(mut self, f: fn(&str) -> bool) -> Self { self.skill_exists = f; self } pub fn validate(&self, graph: &Graph) -> ValidationResult { let mut result = ValidationResult::default(); self.validate_node_references(graph, &mut result); self.validate_cycles(graph, &mut result); self.validate_reachability(graph, &mut result); self.validate_terminal_nodes(graph, &mut result); self.validate_scripts(graph, &mut result); self.validate_agents(graph, &mut result); self.validate_approval_routes(graph, &mut result); self.validate_rag_nodes(graph, &mut result); self.validate_llm_nodes(graph, &mut result); self.validate_llm_skills(graph, &mut result); self.validate_max_attempts(graph, &mut result); self.validate_fallback_capture(graph, &mut result); self.validate_output_schema_properties(graph, &mut result); self.validate_structured_output_user_tools(graph, &mut result); self.validate_llm_node_todo_tools(graph, &mut result); self.validate_max_concurrency(graph, &mut result); self.validate_max_concurrency_template(graph, &mut result); self.validate_orchestration_limits(graph, &mut result); self.validate_timeouts(graph, &mut result); self.validate_variable_shadowing(graph, &mut result); self.validate_map_subgraphs(graph, &mut result); self.validate_parallel_user_interaction(graph, &mut result); self.validate_parallel_writes(graph, &mut result); self.validate_parallel_reads(graph, &mut result); result } fn validate_rag_nodes(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { if let NodeType::Rag(r) = &node.node_type { if r.documents.is_empty() { result.error(ValidationError::with_node( node_id, "RAG node has no 'documents'; at least one knowledge source \ is required", )); } if r.state_updates.is_none() { result.warning(ValidationError::with_node( node_id, "RAG node has no 'state_updates'; its retrieval result will \ not be written to state", )); } if let Some(driver) = &r.driver && let Some(message) = rag_driver_error(driver) { result.error(ValidationError::with_node(node_id, message)); } } } } fn validate_llm_nodes(&self, graph: &Graph, result: &mut ValidationResult) { let Some(ctx) = &self.agent_ctx else { return; }; let expand_alias = |name: &str| config::expand_mcp_server_alias(&ctx.app_config.mapping_mcp_servers, name); let mut enabled_servers: HashSet = ctx.mcp_servers.clone(); for server in &ctx.mcp_servers { enabled_servers.extend(expand_alias(server)); } let all_servers_enabled = ctx.mcp_servers.iter().any(|s| s.trim() == "all"); for (node_id, node) in &graph.nodes { let NodeType::Llm(llm) = &node.node_type else { continue; }; if let Some(tools) = &llm.tools { for entry in tools { if let Some(server) = entry.strip_prefix("mcp:") { if !ctx.mcp_servers.contains(server) { result.error(ValidationError::with_node( node_id, format!("llm node references unknown MCP server 'mcp:{server}'"), )); } } else if !ctx.tool_names.contains(entry) { result.error(ValidationError::with_node( node_id, format!("llm node references unknown tool '{entry}'"), )); } } } if let Some(mcp_tools) = &llm.mcp_tools && !all_servers_enabled { for key in mcp_tools.keys() { let enabled = enabled_servers.contains(key) || expand_alias(key) .iter() .any(|id| enabled_servers.contains(id)); if !enabled { result.error(ValidationError::with_node( node_id, format!( "llm node 'mcp_tools' references MCP server '{key}' not enabled by this graph" ), )); } } } if let Some(model_id) = &llm.model && Model::retrieve_model(ctx.app_config.as_ref(), model_id, ModelType::Chat) .is_err() { result.error(ValidationError::with_node( node_id, format!("llm node references unknown model '{model_id}'"), )); } } } fn validate_llm_skills(&self, graph: &Graph, result: &mut ValidationResult) { let visible_skills = self .agent_ctx .as_ref() .and_then(|c| c.app_config.visible_skills.as_deref()); let skill_exists = self.skill_exists; let has_agent_ctx = self.agent_ctx.is_some(); let check_visibility = |name: &str| -> Option { if !has_agent_ctx { return None; } match visible_skills { Some(list) if !list.iter().any(|s| s == name) => Some(format!( "'{name}' is not in the global 'visible_skills' allow-list" )), None if !skill_exists(name) => Some(format!("'{name}' is not installed")), _ => None, } }; if let Some(graph_skills) = &graph.enabled_skills { for name in graph_skills { if name.trim().is_empty() { result.error(ValidationError::new( "graph 'enabled_skills' contains an empty skill name", )); continue; } if let Err(e) = paths::validate_skill_name(name) { result.error(ValidationError::new(format!( "graph 'enabled_skills' contains an invalid skill name: '{name}': {e}" ))); continue; } if let Some(reason) = check_visibility(name) { result.error(ValidationError::new(format!( "graph 'enabled_skills': {reason}" ))); } } } for (node_id, node) in &graph.nodes { let NodeType::Llm(llm) = &node.node_type else { continue; }; let Some(node_skills) = &llm.enabled_skills else { continue; }; for name in node_skills { if name.trim().is_empty() { result.error(ValidationError::with_node( node_id, "llm node 'enabled_skills' contains an empty skill name", )); continue; } if let Err(e) = paths::validate_skill_name(name) { result.error(ValidationError::with_node( node_id, format!( "llm node 'enabled_skills' contains an invalid skill name: '{name}': {e}" ))); continue; } if let Some(reason) = check_visibility(name) { result.error(ValidationError::with_node( node_id, format!("llm node 'enabled_skills': {reason}"), )); continue; } if let Some(graph_skills) = &graph.enabled_skills && !graph_skills.iter().any(|g| g == name) { result.error(ValidationError::with_node( node_id, format!( "llm node 'enabled_skills' references '{name}' which is not in \ graph-level 'enabled_skills' ({})", graph_skills.join(", ") ), )); } } } } fn validate_max_attempts(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { let kind = match &node.node_type { NodeType::Llm(l) if l.max_attempts == 0 => "llm", NodeType::Agent(a) if a.max_attempts == 0 => "agent", _ => continue, }; result.error(ValidationError::with_node( node_id, format!( "{kind} node's `max_attempts` must be >= 1 (got 0); the node \ would exhaust retries without ever attempting a run" ), )); } } fn validate_fallback_capture(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { let kind = match &node.node_type { NodeType::Llm(l) if l.fallback.is_some() && l.output_schema.is_some() && !lifts_via_state_updates(&l.state_updates) => { "llm" } NodeType::Agent(a) if a.fallback.is_some() && a.output_schema.is_some() && !lifts_via_state_updates(&a.state_updates) => { "agent" } _ => continue, }; result.warning(ValidationError::with_node( node_id, format!( "{kind} node declares `fallback` with `output_schema` but no \ `state_updates`; on failure the \"...failed: \" string is \ only written through `state_updates`, so the fallback node \ cannot see why it was reached" ), )); } } fn validate_output_schema_properties(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { let (kind, schema) = match &node.node_type { NodeType::Llm(l) if !lifts_via_state_updates(&l.state_updates) => { ("llm", l.output_schema.as_ref()) } NodeType::Agent(a) if !lifts_via_state_updates(&a.state_updates) => { ("agent", a.output_schema.as_ref()) } _ => continue, }; let Some(schema) = schema else { continue; }; if schema .get("properties") .and_then(serde_json::Value::as_object) .is_some_and(|m| !m.is_empty()) { continue; } result.warning(ValidationError::with_node( node_id, format!( "{kind} node's `output_schema` declares no (non-empty) object `properties`; \ the engine auto-merges ONLY keys declared under `properties` (a bare \ object schema merges nothing). Declare the keys under \ `properties` or lift them via `state_updates`" ), )); } } fn validate_structured_output_user_tools(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { let NodeType::Llm(llm) = &node.node_type else { continue; }; if llm.output_schema.is_none() { continue; } let exposes_user_tools = llm.tools.as_ref().is_some_and(|tools| { tools .iter() .any(|t| t.trim().starts_with(USER_FUNCTION_PREFIX)) }); if exposes_user_tools { result.warning(ValidationError::with_node( node_id, "structured-output llm node exposes interactive user tools (`user__*`); \ in headless runs a user__ call blocks the node as an escalation", )); } } } fn validate_llm_node_todo_tools(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { let NodeType::Llm(llm) = &node.node_type else { continue; }; let exposes_todo_tools = llm.tools.as_ref().is_some_and(|tools| { tools .iter() .any(|t| t.trim().starts_with(TODO_FUNCTION_PREFIX)) }); if exposes_todo_tools { result.error(ValidationError::with_node( node_id, "llm node exposes todo tools (`todo__*`): todo tools drive chat turn \ loops (auto_continue), which llm nodes don't have. Node continuation \ is owned by the node loop, max_iterations, and graph edges. For driven \ multi-step work, use an agent node whose agent sets `auto_continue: true`", )); } } } fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { for (target, label) in declared_targets(node) { if !graph.has_node(&target) { result.error(ValidationError::with_node( node_id, format!("References non-existent node '{target}' in {label}"), )); } } } } fn validate_cycles(&self, graph: &Graph, result: &mut ValidationResult) { let mut visited: HashSet = HashSet::new(); let mut rec_stack: HashSet = HashSet::new(); let mut path: Vec = Vec::new(); for node_id in graph.node_ids() { if !visited.contains(node_id) && let Some(cycle) = detect_cycle_dfs(graph, node_id, &mut visited, &mut rec_stack, &mut path) { result.error(ValidationError::new(format!( "Cycle detected: {}", cycle.join(" -> ") ))); return; } } } fn validate_reachability(&self, graph: &Graph, result: &mut ValidationResult) { let reachable = find_reachable_nodes(graph); for node_id in graph.node_ids() { if !reachable.contains(node_id) { result.warning(ValidationError::with_node( node_id, "Node is unreachable from the start node via declared edges \ (script `_next` routing is not analyzed)", )); } } } fn validate_terminal_nodes(&self, graph: &Graph, result: &mut ValidationResult) { let has_any_end = graph .nodes .values() .any(|n| matches!(n.node_type, NodeType::End(_))); if !has_any_end { result.error(ValidationError::new( "Graph has no end nodes; execution would never terminate", )); return; } let reachable = find_reachable_nodes(graph); let reachable_end = graph .nodes .iter() .any(|(id, n)| matches!(n.node_type, NodeType::End(_)) && reachable.contains(id)); if !reachable_end { result.warning(ValidationError::new( "No end node is reachable from the start node via declared edges \ (a script's `_next` may still route to one)", )); } } fn validate_scripts(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { if let NodeType::Script(s) = &node.node_type { let script_path = self.base_dir.join(&s.script); if !script_path.exists() { result.error(ValidationError::with_node( node_id, format!("Script file not found: '{}'", script_path.display()), )); } } } } fn validate_agents(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { if let NodeType::Agent(a) = &node.node_type { let agent_dir = paths::agent_data_dir(&a.agent); let has_config = paths::agent_config_file(&a.agent).exists(); let has_graph = paths::agent_graph_file(&a.agent).exists(); if !agent_dir.exists() { result.error(ValidationError::with_node( node_id, format!("Agent '{}' not found (directory missing)", a.agent), )); } else if !has_config && !has_graph { result.error(ValidationError::with_node( node_id, format!( "Agent '{}' has neither a config.yaml nor a graph.yaml", a.agent ), )); } else if has_config && has_graph { result.error(ValidationError::with_node( node_id, format!( "Agent '{}' has both config.yaml and graph.yaml; a graph agent is \ defined by graph.yaml alone. Remove one of the two files", a.agent ), )); } if let Some(inputs) = &a.inputs { if has_config && !has_graph { result.error(ValidationError::with_node( node_id, format!( "agent node '{node_id}': `inputs:` requires agent '{}' to be a graph agent (no graph.yaml found)", a.agent ), )); } if inputs.contains_key("initial_prompt") { result.error(ValidationError::with_node( node_id, format!( "agent node '{node_id}': `inputs.initial_prompt` is reserved (the dispatcher seeds it from `prompt:`)" ), )); } } } } } fn validate_approval_routes(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { if let NodeType::Approval(a) = &node.node_type { for option in &a.options { if !a.routes.contains_key(option) { result.error(ValidationError::with_node( node_id, format!("Approval option '{option}' has no route defined"), )); } } for key in a.routes.keys() { if !a.options.contains(key) { result.warning(ValidationError::with_node( node_id, format!("Route '{key}' has no corresponding option"), )); } } } } } // Parallel-execution validation. // // The v1 algorithm uses immediate-successor analysis only: a parallel group is the set of `next:` targets of a // single fan-out node. Map branch subgraphs are checked separately by `validate_map_subgraphs` (per-item forks // never race each other, so their nodes are not group members). Transitive parallel // groups (deeper fan-out chains) are a v2 enhancement; v1 over-reports rather than under-reports. A false positive // forces an unneeded reducer (mild annoyance); a false negative allows silent data races (catastrophic). fn validate_max_concurrency(&self, graph: &Graph, result: &mut ValidationResult) { if graph.settings.max_concurrency == 0 { result.error(ValidationError::new( "settings.max_concurrency must be >= 1 (got 0); a zero cap would \ deadlock the executor", )); } if graph.settings.max_concurrency > Semaphore::MAX_PERMITS { result.error(ValidationError::new(format!( "settings.max_concurrency must be <= {} (got {}); the runtime cannot allocate more permits", Semaphore::MAX_PERMITS, graph.settings.max_concurrency ))); } for (node_id, node) in &graph.nodes { if let NodeType::Map(m) = &node.node_type && let Some(ConcurrencyCap::Fixed(0)) = m.max_concurrency { result.error(ValidationError::with_node( node_id, "map node's `max_concurrency` must be >= 1 (got 0); a zero cap \ would deadlock the executor", )); } if let NodeType::Map(m) = &node.node_type && let Some(ConcurrencyCap::Fixed(n)) = m.max_concurrency && n > Semaphore::MAX_PERMITS { result.error(ValidationError::with_node( node_id, format!( "map node's `max_concurrency` must be <= {} (got {n}); the runtime cannot allocate more permits", Semaphore::MAX_PERMITS ), )); } } } // A string cap that never interpolates would only fail at run time, after the // map's inputs were already computed; catch the typo (`"4"` for `4`) at load. fn validate_max_concurrency_template(&self, graph: &Graph, result: &mut ValidationResult) { for (node_id, node) in &graph.nodes { if let NodeType::Map(m) = &node.node_type && let Some(ConcurrencyCap::Template(t)) = &m.max_concurrency && !is_lone_template(t) { result.error(ValidationError::with_node( node_id, "map node's `max_concurrency` is a string but not a template; write \ an integer or exactly one `{{key}}` with nothing around it", )); } } } fn validate_orchestration_limits(&self, graph: &Graph, result: &mut ValidationResult) { if graph.max_agent_depth == Some(0) { result.warning(ValidationError::new( "`max_agent_depth: 0` forbids spawning any sub-agent, so every \ `agent` node and dynamic spawn will fail; remove the key or \ set it >= 1", )); } if graph.max_concurrent_agents == Some(0) { result.warning(ValidationError::new( "`max_concurrent_agents: 0` leaves no slot for any spawn to \ acquire, so agent spawning deadlocks; remove the key or set \ it >= 1", )); } } /// Variable seeding never overwrites an existing state key, so a declared /// variable whose name is also an `initial_state` key never reaches graph /// state: the explicit `initial_state` value wins. Legal; the warning only /// makes the shadowing visible. fn validate_variable_shadowing(&self, graph: &Graph, result: &mut ValidationResult) { for var in &graph.variables { if graph.initial_state.contains_key(&var.name) { result.warning(ValidationError::new(format!( "declared variable '{}' is shadowed by an explicit `initial_state` key: \ the `initial_state` value wins and the variable's resolved value \ (spawn-provided/CLI/default) is never seeded into graph state; rename \ one of them, or drop the `initial_state` key to let the variable seed \ it (script nodes' `LLM_AGENT_VAR_` env is unaffected)", var.name ))); } } } /// Explicit `0` opt-outs on `settings` and node bounds: a `timeout: 0` /// disables the wall-clock bound, `max_loop_iterations: 0` disables the /// per-node visit cap, and an llm node's `max_iterations: 0` disables its /// tool-call-loop turn cap. All are legal; the warnings only make the /// choice visible. fn validate_timeouts(&self, graph: &Graph, result: &mut ValidationResult) { if graph.settings.max_loop_iterations == 0 { result.warning(ValidationError::new( "settings.max_loop_iterations: 0 disables the per-node visit cap; a \ non-converging loop runs until an enclosing timeout or abort", )); } if graph.settings.timeout == Some(0) { result.warning(ValidationError::new( "settings.timeout: 0 disables the graph wall-clock bound; the run \ ends only when the graph completes, is aborted, or another \ limit stops it", )); } for (node_id, node) in &graph.nodes { if let NodeType::Llm(l) = &node.node_type && l.max_iterations == 0 { result.warning(ValidationError::with_node( node_id, format!( "max_iterations: 0 disables the turn cap for llm node '{node_id}'; it runs \ until the model concludes, the context window fills, an enclosing timeout, or abort" ), )); } let kind = match &node.node_type { NodeType::Agent(a) if a.timeout == Some(0) => "agent", NodeType::Script(s) if s.timeout == 0 => "script", NodeType::Llm(l) if l.timeout == Some(0) => "llm", NodeType::Rag(r) if r.timeout == Some(0) => "rag", _ => continue, }; result.warning(ValidationError::with_node( node_id, format!( "timeout: 0 disables the wall-clock bound for {kind} node \ '{node_id}'; it runs until it returns or an enclosing bound \ (settings.timeout, an outer agent node's timeout, or abort) ends it" ), )); } } /// Rules for the per-item subgraph rooted at each map's `branch`. Every /// error is attributed to the map node; the offending branch node is /// named in the message. fn validate_map_subgraphs(&self, graph: &Graph, result: &mut ValidationResult) { let main_flow = main_flow_reachable(graph); for (map_id, node) in &graph.nodes { let NodeType::Map(m) = &node.node_type else { continue; }; if m.as_name == m.output_key { result.error(ValidationError::with_node( map_id, format!( "map node '{map_id}': `as` and `output_key` are both '{}'; the item \ binding would be collected as the chain's result", m.as_name ), )); } // A missing entry is reported by `validate_node_references`. let mut member_ids: Vec = branch_subgraph(graph, &m.branch).into_iter().collect(); if member_ids.is_empty() { continue; } member_ids.sort(); let members: Vec<(&str, &Node)> = member_ids .iter() .filter_map(|id| graph.get_node(id).map(|n| (id.as_str(), n))) .collect(); let fallback_targets: HashSet<&str> = members .iter() .filter_map(|(_, n)| match &n.node_type { NodeType::Script(s) => s.fallback.as_deref(), NodeType::Llm(l) => l.fallback.as_deref(), NodeType::Agent(a) => a.fallback.as_deref(), _ => None, }) .collect(); let mut has_disallowed_node = false; for (id, member) in &members { let disallowed = match &member.node_type { NodeType::Approval(_) => Some( "an approval node; approval/input nodes cannot run inside a parallel \ map branch (the CLI would prompt the user N times concurrently)", ), NodeType::Input(_) => { Some("an input node; input nodes cannot run inside a parallel map branch") } NodeType::End(_) => Some( "an end node; map branches terminate via the map's collect \ mechanism, not via end nodes", ), NodeType::Map(_) => { Some("itself a map node; nested map fan-outs are not supported in v1") } NodeType::Agent(_) | NodeType::Llm(_) | NodeType::Rag(_) | NodeType::Script(_) => None, }; if let Some(reason) = disallowed { has_disallowed_node = true; let mut message = if *id == m.branch { format!("map node points to branch '{id}' which is {reason}") } else { format!( "map node's branch subgraph (entry '{}') reaches '{id}', which is \ {reason}", m.branch ) }; if fallback_targets.contains(id) { message.push_str( "; a branch's `fallback` must stay inside the branch subgraph; \ `fallback` on a map branch was previously accepted but \ never honored. Remove it or point it at a branch-local node", ); } result.error(ValidationError::with_node(map_id, message)); } if member.next.as_ref().is_some_and(NextTargets::is_fan_out) { result.error(ValidationError::with_node( map_id, format!( "node '{id}' inside the branch subgraph (entry '{}') declares a \ fan-out `next`; fan-out inside a map branch is not supported in \ v1; use a nested map after the join", m.branch ), )); } } let shared: Vec<&str> = members .iter() .filter(|(id, _)| main_flow.contains(*id)) .map(|(id, _)| *id) .collect(); if !shared.is_empty() { result.error(ValidationError::with_node( map_id, format!( "branch subgraph (entry '{}') shares node(s) [{}] with the main flow \ (reachable from start '{}' without entering a map branch); a branch \ node that is also on the main path would run standalone without the \ `as` binding and could route into the branch mid-chain. Give the \ branch its own nodes, or move the shared step after the map.", m.branch, shared.join(", "), graph.start ), )); } if has_disallowed_node { continue; } // A script may write any key through its JSON output, so its // presence makes the subgraph's write set unknowable. let has_script = members .iter() .any(|(_, n)| matches!(n.node_type, NodeType::Script(_))); if has_script { continue; } let declares_output_key = members.iter().any(|(_, n)| { node_state_updates_keys(n).is_some_and(|keys| keys.contains(&m.output_key)) || output_schema_top_level_keys(n).contains(&m.output_key) }); if !declares_output_key { result.warning(ValidationError::with_node( map_id, format!( "no node in the branch subgraph of map '{map_id}' declares a write to \ output_key '{}'; the map will error at runtime if the chain does not \ write it", m.output_key ), )); } } } fn validate_parallel_user_interaction(&self, graph: &Graph, result: &mut ValidationResult) { for group in compute_parallel_groups(graph) { for node_id in &group { let Some(node) = graph.get_node(node_id) else { continue; }; match &node.node_type { NodeType::Approval(_) => { result.error(ValidationError::with_node( node_id, "approval node is an immediate target of a fan-out \ (`next: [...]`); approvals must run after the join, \ not inside a parallel branch", )); } NodeType::Input(_) => { result.error(ValidationError::with_node( node_id, "input node is an immediate target of a fan-out \ (`next: [...]`); input nodes must run after the join, \ not inside a parallel branch", )); } _ => {} } } } } fn validate_parallel_writes(&self, graph: &Graph, result: &mut ValidationResult) { for group in compute_parallel_groups(graph) { let mut node_writes: Vec<(String, HashSet)> = Vec::new(); for node_id in &group { let Some(node) = graph.get_node(node_id) else { continue; }; match write_set_of(node) { Some(set) => node_writes.push((node_id.clone(), set)), None => { result.error(ValidationError::with_node( node_id, "script node is in a parallel branch but declares no \ `state_updates`; parallel scripts must declare their writes \ explicitly to avoid silent state collisions", )); } } } let mut writers_by_key: BTreeMap> = BTreeMap::new(); for (nid, ws) in &node_writes { for k in ws { writers_by_key .entry(k.clone()) .or_default() .push(nid.clone()); } } for (key, mut writers) in writers_by_key { if writers.len() < 2 { continue; } if graph.reducers.contains_key(&key) { continue; } writers.sort(); result.error(ValidationError::new(format!( "nodes [{}] all write key '{}' in the same parallel super-step but \ no reducer is declared for '{}'. Add `reducers: {{ {}: }}` \ at the graph root (built-ins: append, extend, concat, sum, max, min, \ merge, overwrite), or rename one node's output.", writers.join(", "), key, key, key, ))); } } } fn validate_parallel_reads(&self, graph: &Graph, result: &mut ValidationResult) { for group in compute_parallel_groups(graph) { let nodes: Vec<(&String, &Node)> = group .iter() .filter_map(|id| graph.nodes.get(id).map(|n| (id, n))) .collect(); for (id_a, node_a) in &nodes { let read_set_a = read_set_of(node_a); if read_set_a.is_empty() { continue; } for (id_b, node_b) in &nodes { if id_b == id_a { continue; } let Some(write_set_b) = write_set_of(node_b) else { continue; }; let mut collisions: Vec = read_set_a.intersection(&write_set_b).cloned().collect(); if collisions.is_empty() { continue; } collisions.sort(); let keys = collisions .iter() .map(|k| format!("`{k}`")) .collect::>() .join(", "); result.error(ValidationError::with_node( id_a.as_str(), format!( "node '{id_a}' reads state key(s) {keys} which sibling parallel \ branch '{id_b}' writes in the same super-step; parallel branches \ see a state snapshot taken BEFORE the super-step and cannot observe \ each other's writes. Move the dependent read to a later super-step \ (or remove the cross-branch reference)." ), )); } } } } } fn declared_targets(node: &Node) -> Vec<(String, &'static str)> { let mut out = Vec::new(); if let Some(targets) = &node.next { for target in targets.as_slice() { out.push((target.clone(), "'next'")); } } match &node.node_type { NodeType::Approval(a) => { for v in a.routes.values() { out.push((v.clone(), "approval 'routes'")); } out.push((a.on_other.clone(), "approval 'on_other'")); } NodeType::Script(s) => { if let Some(t) = &s.fallback { out.push((t.clone(), "script 'fallback'")); } } NodeType::Llm(l) => { if let Some(t) = &l.fallback { out.push((t.clone(), "llm 'fallback'")); } } NodeType::Agent(a) => { if let Some(t) = &a.fallback { out.push((t.clone(), "agent 'fallback'")); } } NodeType::Map(m) => { out.push((m.branch.clone(), "map 'branch'")); } // `input`/`rag` route only via `next` (already collected above); // `end` is terminal. No type-specific routing edges to add. NodeType::Input(_) | NodeType::Rag(_) | NodeType::End(_) => {} } out } fn outgoing_node_ids(node: &Node) -> Vec { declared_targets(node).into_iter().map(|(t, _)| t).collect() } fn find_reachable_nodes(graph: &Graph) -> HashSet { let mut reachable: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); if !graph.has_node(&graph.start) { return reachable; } reachable.insert(graph.start.clone()); queue.push_back(graph.start.clone()); while let Some(id) = queue.pop_front() { if let Some(node) = graph.get_node(&id) { for next in outgoing_node_ids(node) { if graph.has_node(&next) && reachable.insert(next.clone()) { queue.push_back(next); } } } } reachable } /// Nodes reachable from the start node without entering any map's branch: /// the path the frontier itself walks. A map node contributes only its /// `next` edges here, so per-item subgraphs can be checked for overlap /// with the main flow. fn main_flow_reachable(graph: &Graph) -> HashSet { let mut reachable: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); if !graph.has_node(&graph.start) { return reachable; } reachable.insert(graph.start.clone()); queue.push_back(graph.start.clone()); while let Some(id) = queue.pop_front() { let Some(node) = graph.get_node(&id) else { continue; }; let edges: Vec = match &node.node_type { NodeType::Map(_) => node .next .as_ref() .map(|t| t.as_slice().to_vec()) .unwrap_or_default(), _ => outgoing_node_ids(node), }; for next in edges { if graph.has_node(&next) && reachable.insert(next.clone()) { queue.push_back(next); } } } reachable } /// Nodes reachable from `entry` over the edges a chain can follow at run /// time: `next` targets and script/llm/agent `fallback`s. Approval routes /// and a nested map's `branch` are not followed. Neither may appear inside a /// branch, and the validator reports them separately. pub(super) fn branch_subgraph(graph: &Graph, entry: &str) -> HashSet { let mut reachable: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); if !graph.has_node(entry) { return reachable; } reachable.insert(entry.to_string()); queue.push_back(entry.to_string()); while let Some(id) = queue.pop_front() { let Some(node) = graph.get_node(&id) else { continue; }; let mut edges: Vec<&String> = node .next .as_ref() .map(|t| t.as_slice().iter().collect()) .unwrap_or_default(); match &node.node_type { NodeType::Script(s) => edges.extend(s.fallback.as_ref()), NodeType::Llm(l) => edges.extend(l.fallback.as_ref()), NodeType::Agent(a) => edges.extend(a.fallback.as_ref()), _ => {} } for next in edges { if graph.has_node(next) && reachable.insert(next.clone()) { queue.push_back(next.clone()); } } } reachable } // v1 parallel-group detection: only the immediate `next` targets of a fan-out node count as a parallel group. Map // branch subgraphs are handled separately by `validate_map_subgraphs` (per-item forks never race each other, so // their nodes are not group members). // // Returns one HashSet per fan-out source; deeper transitive parallelism is intentionally out of scope for v1. fn compute_parallel_groups(graph: &Graph) -> Vec> { let mut groups = Vec::new(); for node in graph.nodes.values() { if let Some(targets) = &node.next && targets.is_fan_out() { groups.push(targets.as_slice().iter().cloned().collect()); } } groups } // Computes the set of state keys this node can write to. // // Sources considered: // - `state_updates` keys (every node type that has them) // - `output_schema` top-level `properties` for `llm` and `agent` (auto-merge) fn write_set_of(node: &Node) -> Option> { if matches!(node.node_type, NodeType::Script(_)) && node_state_updates_keys(node).is_none() { return None; } let mut writes = HashSet::new(); if let Some(keys) = node_state_updates_keys(node) { writes.extend(keys); } writes.extend(output_schema_top_level_keys(node)); Some(writes) } // Computes the set of root state keys this node's templated fields read from. // // "Root key" follows the same definition as `template_root_keys`: for a // reference like `{{user.name}}` or `{{items[0]}}`, the root is the bare // identifier before the first `.` or `[`. fn read_set_of(node: &Node) -> HashSet { let mut reads: HashSet = HashSet::new(); let scoped: &[&str] = match &node.node_type { NodeType::Llm(_) | NodeType::Agent(_) | NodeType::Rag(_) => &["output"], NodeType::Approval(_) => &["choice"], NodeType::Input(_) => &["input"], NodeType::Script(_) | NodeType::End(_) | NodeType::Map(_) => &[], }; for s in primary_templated_fields(node) { for k in template_root_keys(&s) { reads.insert(k); } } if let Some(updates) = node_state_updates_map(node) { for v in updates.values() { for k in template_root_keys(v) { if !scoped.contains(&k.as_str()) { reads.insert(k); } } } } reads } fn primary_templated_fields(node: &Node) -> Vec { match &node.node_type { NodeType::Llm(n) => { let mut v = vec![n.prompt.clone()]; if let Some(i) = &n.instructions { v.push(i.clone()); } v } NodeType::Agent(n) => { let mut v = vec![n.prompt.clone()]; if let Some(inputs) = &n.inputs { v.extend(inputs.values().cloned()); } v } NodeType::Rag(n) => { vec![ n.query .clone() .unwrap_or_else(|| "{{initial_prompt}}".to_string()), ] } NodeType::Approval(n) => vec![n.question.clone()], NodeType::Input(n) => { let mut v = vec![n.question.clone()]; if let Some(d) = &n.default { v.push(d.clone()); } v } NodeType::End(n) => vec![n.output.clone()], NodeType::Map(n) => { let mut v = vec![n.over.clone()]; if let Some(ConcurrencyCap::Template(t)) = &n.max_concurrency { v.push(t.clone()); } v } NodeType::Script(_) => Vec::new(), } } fn node_state_updates_map(node: &Node) -> Option<&HashMap> { match &node.node_type { NodeType::Llm(n) => n.state_updates.as_ref(), NodeType::Agent(n) => n.state_updates.as_ref(), NodeType::Rag(n) => n.state_updates.as_ref(), NodeType::Approval(n) => n.state_updates.as_ref(), NodeType::Input(n) => n.state_updates.as_ref(), NodeType::Script(n) => n.state_updates.as_ref(), NodeType::End(n) => n.state_updates.as_ref(), NodeType::Map(_) => None, } } fn lifts_via_state_updates(updates: &Option>) -> bool { updates.as_ref().is_some_and(|m| !m.is_empty()) } fn node_state_updates_keys(node: &Node) -> Option> { let updates = match &node.node_type { NodeType::Agent(n) => n.state_updates.as_ref(), NodeType::Script(n) => n.state_updates.as_ref(), NodeType::Approval(n) => n.state_updates.as_ref(), NodeType::Input(n) => n.state_updates.as_ref(), NodeType::Llm(n) => n.state_updates.as_ref(), NodeType::Rag(n) => n.state_updates.as_ref(), NodeType::End(n) => n.state_updates.as_ref(), NodeType::Map(_) => return None, }; updates.map(|m| m.keys().cloned().collect()) } fn output_schema_top_level_keys(node: &Node) -> HashSet { let schema = match &node.node_type { NodeType::Agent(n) => n.output_schema.as_ref(), NodeType::Llm(n) => n.output_schema.as_ref(), _ => return HashSet::new(), }; let Some(schema) = schema else { return HashSet::new(); }; let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else { return HashSet::new(); }; properties.keys().cloned().collect() } fn detect_cycle_dfs( graph: &Graph, node_id: &str, visited: &mut HashSet, rec_stack: &mut HashSet, path: &mut Vec, ) -> Option> { visited.insert(node_id.to_string()); rec_stack.insert(node_id.to_string()); path.push(node_id.to_string()); if let Some(node) = graph.get_node(node_id) { for next in outgoing_node_ids(node) { if !graph.has_node(&next) { continue; } if !visited.contains(&next) { if let Some(cycle) = detect_cycle_dfs(graph, &next, visited, rec_stack, path) { return Some(cycle); } } else if rec_stack.contains(&next) { let start = path.iter().position(|n| n == &next).unwrap_or(0); let mut cycle: Vec = path[start..].to_vec(); cycle.push(next.clone()); return Some(cycle); } } } path.pop(); rec_stack.remove(node_id); None } #[cfg(test)] mod tests { use super::super::types::*; use super::*; use crate::utils::get_env_name; use indexmap::IndexMap; use serial_test::serial; use std::collections::HashMap; use std::env; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; fn graph_with(nodes: Vec<(&str, Node)>, start: &str) -> Graph { let mut map: IndexMap = IndexMap::new(); for (id, node) in nodes { map.insert(id.to_string(), node); } Graph { name: "t".into(), description: String::new(), version: "1.0".into(), model: None, temperature: None, top_p: None, reasoning_effort: None, max_concurrent_jobs: None, can_spawn_agents: None, max_concurrent_agents: None, max_agent_depth: None, global_tools: Vec::new(), hooks: Default::default(), global_hooks: Vec::new(), mcp_servers: Vec::new(), mcp_tools: None, skills_enabled: None, enabled_skills: None, inject_skill_instructions: None, skill_instructions: None, conversation_starters: Vec::new(), variables: Vec::new(), settings: GraphSettings::default(), initial_state: HashMap::new(), reducers: HashMap::new(), start: start.into(), nodes: map, } } fn end_node(id: &str) -> Node { Node { id: id.into(), description: String::new(), node_type: NodeType::End(EndNode { output: String::new(), state_updates: None, }), next: None, } } fn approval_node(id: &str, options: &[&str], routes: &[(&str, &str)], on_other: &str) -> Node { let mut r: HashMap = HashMap::new(); for (k, v) in routes { r.insert((*k).into(), (*v).into()); } Node { id: id.into(), description: String::new(), node_type: NodeType::Approval(ApprovalNode { question: "?".into(), options: options.iter().map(|s| (*s).into()).collect(), routes: r, on_other: on_other.into(), state_updates: None, }), next: None, } } fn script_node(id: &str, script: &str, fallback: Option<&str>) -> Node { Node { id: id.into(), description: String::new(), node_type: NodeType::Script(ScriptNode { script: script.into(), state_updates: None, fallback: fallback.map(String::from), timeout: 30, }), next: None, } } fn rag_node(id: &str, documents: &[&str], with_state_updates: bool) -> Node { let state_updates = with_state_updates.then(|| { let mut m: HashMap = HashMap::new(); m.insert("ctx".into(), "{{output.context}}".into()); m }); Node { id: id.into(), description: String::new(), node_type: NodeType::Rag(RagNode { documents: documents.iter().map(|s| (*s).into()).collect(), query: None, top_k: None, embedding_model: None, chunk_size: None, chunk_overlap: None, reranker_model: None, batch_size: None, extractor_model: None, extractor_prompt: None, graph_hops: None, driver: None, state_updates, timeout: None, }), next: Some("end".into()), } } fn llm_node(id: &str, fallback: Option<&str>, next: Option<&str>) -> Node { Node { id: id.into(), description: String::new(), node_type: NodeType::Llm(LlmNode { instructions: None, prompt: "p".into(), tools: None, mcp_tools: None, model: None, temperature: None, top_p: None, reasoning_effort: None, fallback: fallback.map(String::from), max_attempts: 1, max_iterations: 10, state_updates: None, output_schema: None, timeout: None, skills_enabled: None, enabled_skills: None, inject_skill_instructions: None, skill_instructions: None, }), next: next.map(NextTargets::from), } } #[test] fn flags_missing_llm_fallback_target() { let graph = graph_with( vec![ ("l", llm_node("l", Some("ghost"), Some("end"))), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!(result.errors.iter().any(|e| e.message.contains("ghost"))); } #[test] fn llm_node_skill_in_graph_set_passes() { let mut graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); graph.enabled_skills = Some(vec!["code-review".into(), "git-master".into()]); if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type { n.enabled_skills = Some(vec!["code-review".into()]); } let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("enabled_skills")), "unexpected enabled_skills error: {:?}", result.errors ); } #[test] fn llm_node_skill_not_in_graph_set_errors() { let mut graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); graph.enabled_skills = Some(vec!["code-review".into()]); if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type { n.enabled_skills = Some(vec!["git-master".into()]); } let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("'git-master'") && e.message.contains("graph-level")), "expected git-master subset error, got: {:?}", result.errors ); } #[test] fn llm_node_empty_skill_name_errors() { let mut graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); graph.enabled_skills = Some(vec!["code-review".into()]); if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type { n.enabled_skills = Some(vec!["".into()]); } let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("empty skill name")), "expected empty-skill-name error, got: {:?}", result.errors ); } #[test] fn llm_node_skill_when_no_graph_set_is_permitted_by_validator() { let mut graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type { n.enabled_skills = Some(vec!["anything".into()]); } let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("enabled_skills")), "validator should not block when graph.enabled_skills is None: {:?}", result.errors ); } fn agent_ctx(tools: &[&str], mcp: &[&str]) -> AgentValidationContext { AgentValidationContext { tool_names: tools.iter().map(|s| s.to_string()).collect(), mcp_servers: mcp.iter().map(|s| s.to_string()).collect(), app_config: Arc::new(AppConfig::default()), } } fn llm_node_with(id: &str, tools: Option>, model: Option<&str>) -> Node { let mut node = llm_node(id, None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = tools.map(|t| t.iter().map(|s| s.to_string()).collect()); n.model = model.map(String::from); } node } fn llm_node_with_mcp_tools(id: &str, servers: &[&str]) -> Node { let mut node = llm_node(id, None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { let mut mcp_tools = IndexMap::new(); for server in servers { mcp_tools.insert(server.to_string(), vec!["get_*".to_string()]); } n.mcp_tools = Some(mcp_tools); } node } #[test] fn llm_node_unknown_tool_is_an_error() { let graph = graph_with( vec![ ("l", llm_node_with("l", Some(vec!["bogus_tool"]), None)), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&["read_query"], &[])) .validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("bogus_tool")) ); } #[test] fn llm_node_known_tool_passes() { let graph = graph_with( vec![ ("l", llm_node_with("l", Some(vec!["read_query"]), None)), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&["read_query"], &[])) .validate(&graph); assert!(result.is_valid()); } #[test] fn llm_node_unknown_mcp_server_is_an_error() { let graph = graph_with( vec![ ("l", llm_node_with("l", Some(vec!["mcp:bogus"]), None)), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &["pubmed-search"])) .validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("mcp:bogus")) ); } #[test] fn llm_node_known_mcp_server_passes() { let graph = graph_with( vec![ ( "l", llm_node_with("l", Some(vec!["mcp:pubmed-search"]), None), ), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &["pubmed-search"])) .validate(&graph); assert!(result.is_valid()); } #[test] fn llm_node_mcp_tools_enabled_server_passes() { let graph = graph_with( vec![ ("l", llm_node_with_mcp_tools("l", &["github"])), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &["github"])) .validate(&graph); assert!(result.is_valid()); } #[test] fn llm_node_mcp_tools_unknown_server_is_an_error() { let graph = graph_with( vec![ ("l", llm_node_with_mcp_tools("l", &["slack"])), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &["github"])) .validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("'slack' not enabled")) ); } #[test] fn llm_node_mcp_tools_alias_key_passes() { let graph = graph_with( vec![ ("l", llm_node_with_mcp_tools("l", &["gh"])), ("end", end_node("end")), ], "l", ); let mut ctx = agent_ctx(&[], &["github-mcp"]); let mut app = AppConfig::default(); app.mapping_mcp_servers .insert("gh".to_string(), "github-mcp".to_string()); ctx.app_config = Arc::new(app); let result = validator().with_agent_context(ctx).validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); } #[test] fn llm_node_mcp_tools_key_matching_alias_expansion_passes() { let graph = graph_with( vec![ ("l", llm_node_with_mcp_tools("l", &["github-mcp"])), ("end", end_node("end")), ], "l", ); let mut ctx = agent_ctx(&[], &["gh"]); let mut app = AppConfig::default(); app.mapping_mcp_servers .insert("gh".to_string(), "github-mcp".to_string()); ctx.app_config = Arc::new(app); let result = validator().with_agent_context(ctx).validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); } #[test] fn llm_node_mcp_tools_with_all_sentinel_passes() { let graph = graph_with( vec![ ("l", llm_node_with_mcp_tools("l", &["github"])), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &["all"])) .validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); } #[test] fn llm_node_unknown_model_is_an_error() { let graph = graph_with( vec![ ("l", llm_node_with("l", None, Some("nonexistent:model"))), ("end", end_node("end")), ], "l", ); let result = validator() .with_agent_context(agent_ctx(&[], &[])) .validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("nonexistent:model")) ); } #[test] fn llm_node_validation_skipped_without_agent_context() { let graph = graph_with( vec![ ("l", llm_node_with("l", Some(vec!["bogus_tool"]), None)), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!(result.is_valid()); } #[test] fn rag_node_without_documents_errors() { let graph = graph_with( vec![("r", rag_node("r", &[], true)), ("end", end_node("end"))], "r", ); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("no 'documents'") && e.node_id.as_deref() == Some("r")) ); } #[test] fn rag_node_without_state_updates_warns() { let graph = graph_with( vec![ ("r", rag_node("r", &["./docs"], false)), ("end", end_node("end")), ], "r", ); let result = validator().validate(&graph); assert!(result.is_valid()); assert!( result .warnings .iter() .any(|w| w.message.contains("no 'state_updates'")) ); } #[test] fn valid_rag_node_produces_no_findings() { let graph = graph_with( vec![ ("r", rag_node("r", &["./docs"], true)), ("end", end_node("end")), ], "r", ); let result = validator().validate(&graph); assert!(result.is_valid()); assert!( !result .warnings .iter() .any(|w| w.message.contains("RAG node")) ); } /// Guards the fail-open branch in `rag_driver_error`. If this fails, /// `RagData::validate()` grew a precondition the probe fixture no longer /// satisfies and rag-node driver validation has silently switched itself off. /// Repair the fixture in `rag_driver_probe`; do not delete this test. #[test] fn rag_driver_probe_fixture_is_accepted() { let default_driver = RagData::default().driver; assert!( rag_driver_probe(&default_driver).validate().is_ok(), "probe fixture rejected for the default driver '{default_driver}'" ); } #[test] fn rag_driver_error_defers_to_ragdata_validate() { assert_eq!(rag_driver_error("yaml"), None); assert_eq!(rag_driver_error("duckdb"), None); let message = rag_driver_error("duckdbb").expect("unknown driver must be rejected"); assert!(message.contains("duckdbb"), "got: {message}"); } #[test] fn rag_node_with_unknown_driver_errors_naming_the_node() { let mut node = rag_node("kb", &["./docs"], true); if let NodeType::Rag(ref mut r) = node.node_type { r.driver = Some("postgres".into()); } let graph = graph_with(vec![("kb", node), ("end", end_node("end"))], "kb"); let result = validator().validate(&graph); assert!(!result.is_valid()); let err = result.into_result().unwrap_err().to_string(); assert!(err.contains("[kb]"), "must name the node: {err}"); assert!(err.contains("postgres"), "must name the driver: {err}"); } #[test] fn rag_node_with_duckdb_driver_produces_no_findings() { let mut node = rag_node("kb", &["./docs"], true); if let NodeType::Rag(ref mut r) = node.node_type { r.driver = Some("duckdb".into()); } let graph = graph_with(vec![("kb", node), ("end", end_node("end"))], "kb"); assert!(validator().validate(&graph).is_valid()); } fn agent_node(id: &str, agent: &str, next: Option<&str>) -> Node { Node { id: id.into(), description: String::new(), node_type: NodeType::Agent(AgentNode { agent: agent.into(), prompt: "hi".into(), state_updates: None, output_schema: None, timeout: None, max_attempts: 1, fallback: None, inputs: None, teammates: false, }), next: next.map(NextTargets::from), } } fn validator() -> GraphValidator { GraphValidator::new(env::current_dir().unwrap()).with_skill_exists(|_: &str| true) } #[test] fn valid_simple_graph_passes() { let mut start = end_node("start"); start.next = Some("end".into()); let graph = graph_with(vec![("start", start), ("end", end_node("end"))], "start"); let result = validator().validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); } #[test] fn flags_missing_node_reference_in_next() { let mut n = end_node("n1"); n.next = Some("nope".into()); let graph = graph_with(vec![("n1", n), ("end", end_node("end"))], "n1"); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'nope'") && e.node_id.as_deref() == Some("n1")) ); } #[test] fn flags_missing_approval_route_target() { let approval = approval_node( "ap", &["yes", "no"], &[("yes", "end"), ("no", "missing")], "end", ); let graph = graph_with(vec![("ap", approval), ("end", end_node("end"))], "ap"); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'missing'")) ); } #[test] fn flags_missing_approval_on_other_target() { let approval = approval_node("ap", &["yes"], &[("yes", "end")], "missing"); let graph = graph_with(vec![("ap", approval), ("end", end_node("end"))], "ap"); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'missing'") && e.message.contains("on_other")) ); } #[test] fn flags_missing_script_fallback_target() { let scr = script_node("s", "does-not-exist.py", Some("nowhere")); let graph = graph_with(vec![("s", scr), ("end", end_node("end"))], "s"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'nowhere'")) ); } #[test] fn flags_missing_agent_fallback_target() { let mut a = agent_node("a", "helper", Some("end")); if let NodeType::Agent(ref mut an) = a.node_type { an.fallback = Some("ghost".into()); } let graph = graph_with(vec![("a", a), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'ghost'") && e.message.contains("agent 'fallback'")) ); } #[test] fn agent_fallback_target_is_not_flagged_unreachable() { let mut a = agent_node("a", "helper", Some("end")); if let NodeType::Agent(ref mut an) = a.node_type { an.fallback = Some("recover".into()); } let recover = agent_node("recover", "helper", Some("end")); let graph = graph_with( vec![("a", a), ("recover", recover), ("end", end_node("end"))], "a", ); let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|w| w.node_id.as_deref() == Some("recover") && w.message.contains("unreachable")), "fallback target 'recover' incorrectly marked unreachable: {:?}", result.warnings ); } #[test] fn detects_two_node_cycle() { let mut a = end_node("a"); a.next = Some("b".into()); let mut b = end_node("b"); b.next = Some("a".into()); let graph = graph_with(vec![("a", a), ("b", b)], "a"); let result = validator().validate(&graph); assert!(!result.is_valid()); assert!( result .errors .iter() .any(|e| e.message.contains("Cycle detected")) ); } #[test] fn detects_self_loop_as_cycle() { let mut a = end_node("a"); a.next = Some("a".into()); let graph = graph_with(vec![("a", a)], "a"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("Cycle detected")) ); } #[test] fn warns_on_unreachable_node() { let graph = graph_with( vec![("start", end_node("start")), ("orphan", end_node("orphan"))], "start", ); let result = validator().validate(&graph); assert!( result.warnings.iter().any( |w| w.node_id.as_deref() == Some("orphan") && w.message.contains("unreachable") ) ); } #[test] fn errors_when_graph_has_no_end_node_at_all() { let mut a = agent_node("a", "__no_such_agent__", Some("b")); let b = agent_node("b", "__no_such_agent__", None); a.next = Some("b".into()); let graph = graph_with(vec![("a", a), ("b", b)], "a"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("no end nodes")), "errors: {:?}", result.errors ); } #[test] fn warns_when_end_exists_but_not_reachable() { let start = Node { id: "start".into(), description: String::new(), node_type: NodeType::Input(InputNode { question: "?".into(), default: None, validation: None, state_updates: None, }), next: None, }; let graph = graph_with( vec![("start", start), ("orphan_end", end_node("orphan_end"))], "start", ); let result = validator().validate(&graph); assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); assert!( result .warnings .iter() .any(|w| w.message.contains("No end node is reachable")) ); } #[test] fn errors_when_script_file_missing() { let scr = script_node("s", "definitely-not-here.py", None); let mut start = end_node("start"); start.next = Some("s".into()); let graph = graph_with( vec![("start", start), ("s", scr), ("end", end_node("end"))], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("Script file not found") && e.node_id.as_deref() == Some("s")) ); } #[test] fn errors_when_referenced_agent_missing() { let agent = agent_node("a", "__definitely_no_such_agent__", Some("end")); let graph = graph_with(vec![("a", agent), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!(result.errors.iter().any(|e| { e.message .contains("Agent '__definitely_no_such_agent__' not found") })); } #[test] fn errors_when_approval_option_has_no_route() { let approval = approval_node("ap", &["yes", "no"], &[("yes", "end")], "end"); let graph = graph_with(vec![("ap", approval), ("end", end_node("end"))], "ap"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("'no' has no route defined")) ); } #[test] fn warns_when_approval_has_extra_route() { let approval = approval_node("ap", &["yes"], &[("yes", "end"), ("maybe", "end")], "end"); let graph = graph_with(vec![("ap", approval), ("end", end_node("end"))], "ap"); let result = validator().validate(&graph); assert!(result.warnings.iter().any(|w| { w.message .contains("Route 'maybe' has no corresponding option") })); } #[test] fn into_result_aggregates_all_errors() { let mut a = end_node("a"); a.next = Some("missing1".into()); let mut b = end_node("b"); b.next = Some("missing2".into()); let graph = graph_with(vec![("a", a), ("b", b)], "a"); let err = validator() .validate(&graph) .into_result() .unwrap_err() .to_string(); assert!(err.contains("missing1"), "got: {err}"); assert!(err.contains("missing2"), "got: {err}"); assert!(err.contains("validation failed"), "got: {err}"); } #[test] fn into_result_returns_ok_when_no_errors() { let mut start = end_node("start"); start.next = Some("end".into()); let graph = graph_with(vec![("start", start), ("end", end_node("end"))], "start"); assert!(validator().validate(&graph).into_result().is_ok()); } #[test] fn cycle_detector_treats_fan_out_diamond_as_a_valid_dag() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["a".into(), "b".into()])); let mut a = end_node("a"); a.next = Some("join".into()); let mut b = end_node("b"); b.next = Some("join".into()); let mut join = end_node("join"); join.next = Some("end".into()); let graph = graph_with( vec![ ("start", start), ("a", a), ("b", b), ("join", join), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("Cycle detected")), "fan-out diamond incorrectly reported as cycle: {:?}", result.errors ); } #[test] fn reachability_visits_every_member_of_many_next_targets() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["a".into(), "b".into(), "c".into()])); let graph = graph_with( vec![ ("start", start), ("a", end_node("a")), ("b", end_node("b")), ("c", end_node("c")), ], "start", ); let result = validator().validate(&graph); for orphan in ["a", "b", "c"] { assert!( !result .warnings .iter() .any(|w| w.node_id.as_deref() == Some(orphan) && w.message.contains("unreachable")), "fan-out target '{orphan}' incorrectly marked unreachable: {:?}", result.warnings ); } } #[test] fn node_reference_check_catches_missing_member_inside_many() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["a".into(), "ghost".into()])); let graph = graph_with(vec![("start", start), ("a", end_node("a"))], "start"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'ghost'") && e.node_id.as_deref() == Some("start")), "expected error for missing 'ghost' target in Many: {:?}", result.errors ); } #[test] fn node_reference_check_catches_missing_map_branch_target() { let map = Node { id: "fan".into(), description: String::new(), node_type: NodeType::Map(MapNode { over: "{{items}}".into(), as_name: "item".into(), branch: "no_such_node".into(), output_key: "output".into(), collect_into: "results".into(), max_concurrency: None, }), next: Some("end".into()), }; let graph = graph_with(vec![("fan", map), ("end", end_node("end"))], "fan"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("non-existent node 'no_such_node'") && e.message.contains("map 'branch'")), "expected error for missing map branch: {:?}", result.errors ); } fn map_node_basic(id: &str, branch: &str, next: Option<&str>) -> Node { Node { id: id.into(), description: String::new(), node_type: NodeType::Map(MapNode { over: "{{items}}".into(), as_name: "item".into(), branch: branch.into(), output_key: "output".into(), collect_into: "results".into(), max_concurrency: None, }), next: next.map(NextTargets::from), } } fn llm_with_state_updates(id: &str, updates: &[(&str, &str)], next: Option<&str>) -> Node { let mut node = llm_node(id, None, next); if let NodeType::Llm(ref mut n) = node.node_type { let mut map: HashMap = HashMap::new(); for (k, v) in updates { map.insert((*k).into(), (*v).into()); } n.state_updates = Some(map); } node } fn llm_with_output_schema(id: &str, properties: &[&str], next: Option<&str>) -> Node { let mut node = llm_node(id, None, next); if let NodeType::Llm(ref mut n) = node.node_type { let mut props = serde_json::Map::new(); for k in properties { props.insert((*k).to_string(), serde_json::json!({ "type": "string" })); } n.output_schema = Some(serde_json::json!({ "type": "object", "properties": props, })); } node } fn script_with_state_updates(id: &str, updates: &[(&str, &str)]) -> Node { let mut node = script_node(id, "Cargo.toml", None); if let NodeType::Script(ref mut n) = node.node_type { let mut map: HashMap = HashMap::new(); for (k, v) in updates { map.insert((*k).into(), (*v).into()); } n.state_updates = Some(map); } node } fn fan_out_graph_with_two_workers(worker_a: Node, worker_b: Node) -> Graph { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec![ "worker_a".into(), "worker_b".into(), ])); graph_with( vec![ ("start", start), ("worker_a", worker_a), ("worker_b", worker_b), ("end", end_node("end")), ], "start", ) } #[test] fn parallel_writes_to_same_key_without_reducer_errors() { let a = llm_with_state_updates("worker_a", &[("summary", "{{output}}")], Some("end")); let b = llm_with_state_updates("worker_b", &[("summary", "{{output}}")], Some("end")); let graph = fan_out_graph_with_two_workers(a, b); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .contains("nodes [worker_a, worker_b] all write key 'summary'")), "expected reducer-collision error for `summary`: {:?}", result.errors ); } #[test] fn parallel_writes_to_same_key_with_reducer_pass() { let a = llm_with_state_updates("worker_a", &[("summary", "{{output}}")], Some("end")); let b = llm_with_state_updates("worker_b", &[("summary", "{{output}}")], Some("end")); let mut graph = fan_out_graph_with_two_workers(a, b); graph.reducers.insert("summary".into(), Reducer::Concat); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("no reducer is declared")), "expected no reducer-collision error when reducer is declared: {:?}", result.errors ); } #[test] fn parallel_writes_to_disjoint_keys_pass() { let a = llm_with_state_updates("worker_a", &[("a_out", "{{output}}")], Some("end")); let b = llm_with_state_updates("worker_b", &[("b_out", "{{output}}")], Some("end")); let graph = fan_out_graph_with_two_workers(a, b); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("no reducer is declared")), "expected no collision for disjoint keys: {:?}", result.errors ); } #[test] fn output_schema_top_level_keys_count_as_parallel_writes() { let a = llm_with_output_schema("worker_a", &["summary"], Some("end")); let b = llm_with_output_schema("worker_b", &["summary"], Some("end")); let graph = fan_out_graph_with_two_workers(a, b); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .contains("nodes [worker_a, worker_b] all write key 'summary'")), "output_schema top-level keys should count as writes: {:?}", result.errors ); } #[test] fn three_parallel_writers_collision_lists_all_writers() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["a".into(), "b".into(), "c".into()])); let graph = graph_with( vec![ ("start", start), ( "a", llm_with_state_updates("a", &[("k", "{{output}}")], Some("end")), ), ( "b", llm_with_state_updates("b", &[("k", "{{output}}")], Some("end")), ), ( "c", llm_with_state_updates("c", &[("k", "{{output}}")], Some("end")), ), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("nodes [a, b, c]") && e.message.contains("'k'")), "expected error listing all three writers a, b, c: {:?}", result.errors ); } #[test] fn approval_node_as_immediate_fan_out_target_errors() { let approval = approval_node("ap", &["yes"], &[("yes", "end")], "end"); let other = end_node("other"); let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["ap".into(), "other".into()])); let graph = graph_with( vec![ ("start", start), ("ap", approval), ("other", other), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("approval node") && e.message.contains("fan-out") && e.node_id.as_deref() == Some("ap")), "expected approval-in-fan-out error: {:?}", result.errors ); } #[test] fn input_node_as_immediate_fan_out_target_errors() { let input = Node { id: "in".into(), description: String::new(), node_type: NodeType::Input(InputNode { question: "?".into(), default: None, validation: None, state_updates: None, }), next: Some("end".into()), }; let other = end_node("other"); let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["in".into(), "other".into()])); let graph = graph_with( vec![ ("start", start), ("in", input), ("other", other), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("input node") && e.message.contains("fan-out") && e.node_id.as_deref() == Some("in")), "expected input-in-fan-out error: {:?}", result.errors ); } #[test] fn approval_after_join_passes() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["a".into(), "b".into()])); let mut a = end_node("a"); a.next = Some("ap".into()); let mut b = end_node("b"); b.next = Some("ap".into()); let approval = approval_node("ap", &["yes"], &[("yes", "end")], "end"); let graph = graph_with( vec![ ("start", start), ("a", a), ("b", b), ("ap", approval), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("fan-out") && e.node_id.as_deref() == Some("ap")), "approval AFTER join should be fine (v1 only checks immediate successors): {:?}", result.errors ); } #[test] fn map_branch_cannot_be_approval() { let map = map_node_basic("m", "br", Some("end")); let branch = approval_node("br", &["yes"], &[("yes", "end")], "end"); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("approval node") && e.message.contains("map branch") && e.node_id.as_deref() == Some("m")), "expected map-branch-is-approval error: {:?}", result.errors ); } #[test] fn map_branch_cannot_be_input() { let map = map_node_basic("m", "br", Some("end")); let branch = Node { id: "br".into(), description: String::new(), node_type: NodeType::Input(InputNode { question: "?".into(), default: None, validation: None, state_updates: None, }), next: Some("end".into()), }; let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("input node") && e.message.contains("map branch") && e.node_id.as_deref() == Some("m")), "expected map-branch-is-input error: {:?}", result.errors ); } #[test] fn map_branch_cannot_be_end() { let map = map_node_basic("m", "br", Some("done")); let graph = graph_with( vec![ ("m", map), ("br", end_node("br")), ("done", end_node("done")), ], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("end node") && e.message.contains("collect mechanism") && e.node_id.as_deref() == Some("m")), "expected map-branch-is-end error: {:?}", result.errors ); } #[test] fn map_branch_cannot_be_another_map() { let outer = map_node_basic("outer", "inner", Some("end")); let inner = map_node_basic("inner", "end", Some("end")); let graph = graph_with( vec![("outer", outer), ("inner", inner), ("end", end_node("end"))], "outer", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("itself a map node") && e.node_id.as_deref() == Some("outer")), "expected nested-map error: {:?}", result.errors ); } #[test] fn map_branch_with_in_subgraph_next_passes() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("br2")); let br2 = llm_with_state_updates("br2", &[("output", "{{output}}")], None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("br2", br2), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!( result.errors.is_empty(), "a branch chaining to a node inside its own subgraph is valid: {:?}", result.errors ); } #[test] fn map_branch_next_to_end_node_errors() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], Some("somewhere")); let graph = graph_with( vec![ ("m", map), ("br", branch), ("somewhere", end_node("somewhere")), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("'somewhere'") && e.message.contains("end node") && e.message.contains("collect mechanism") && !e.message.contains("never honored") && e.node_id.as_deref() == Some("m")), "expected end-node-in-subgraph error attributed to the map: {:?}", result.errors ); } #[test] fn map_branch_next_escaping_to_main_flow_errors() { let map = map_node_basic("m", "br", Some("after")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], Some("after")); let after = llm_node("after", None, Some("end")); let graph = graph_with( vec![ ("m", map), ("br", branch), ("after", after), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("main flow") && e.message.contains("[after") && e.node_id.as_deref() == Some("m")), "expected shared-with-main-flow error naming 'after': {:?}", result.errors ); } #[test] fn map_branch_state_updates_matching_output_key_passes() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( !result.errors.iter().any(|e| e .message .contains("map branches may only write through their `output_key`")), "valid map branch should not error on writes: {:?}", result.errors ); } #[test] fn map_branch_state_updates_scratch_key_passes() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_state_updates("br", &[("not_output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.is_empty(), "branch-local scratch writes are allowed: {:?}", result.errors ); } #[test] fn map_branch_with_output_schema_passes() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_output_schema("br", &["foo", "bar"], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.is_empty(), "an output_schema on a branch node is allowed: {:?}", result.errors ); } #[test] fn map_branch_disallowed_node_reached_via_fallback_gets_hint() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", Some("end"), None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("'end'") && e.message.contains("end node") && e.message.contains("never honored") && e.node_id.as_deref() == Some("m")), "expected end-node error with the fallback hint: {:?}", result.errors ); } #[test] fn map_branch_agent_fallback_escaping_the_branch_gets_hint() { let map = map_node_basic("m", "br", Some("end")); let mut branch = agent_node("br", "helper", None); if let NodeType::Agent(ref mut an) = branch.node_type { an.fallback = Some("end".into()); } let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("'end'") && e.message.contains("end node") && e.message.contains("never honored") && e.node_id.as_deref() == Some("m")), "expected end-node error with the fallback hint: {:?}", result.errors ); } #[test] fn map_branch_agent_fallback_to_branch_local_node_passes() { let map = map_node_basic("m", "br", Some("end")); let mut branch = agent_node("br", "helper", None); if let NodeType::Agent(ref mut an) = branch.node_type { an.fallback = Some("recover".into()); } let recover = agent_node("recover", "helper", None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("recover", recover), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); // Agent-existence errors on 'br'/'recover' are expected (no agent // dirs in the test cwd); branch-locality errors land on the map. assert!( !result .errors .iter() .any(|e| e.node_id.as_deref() == Some("m")), "a branch-local agent fallback is allowed: {:?}", result.errors ); } #[test] fn map_branch_llm_fallback_to_branch_local_script_passes() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", Some("recover"), None); let recover = script_node("recover", "Cargo.toml", None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("recover", recover), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!( !result .warnings .iter() .any(|w| w.node_id.as_deref() == Some("m")), "a branch-local script fallback raises nothing on the map: {:?}", result.warnings ); } #[test] fn map_branch_deep_approval_node_errors_on_the_map() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("gate")); let gate = approval_node("gate", &["yes"], &[("yes", "end")], "end"); let graph = graph_with( vec![ ("m", map), ("br", branch), ("gate", gate), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("'gate'") && e.message.contains("approval node") && e.message.contains("map branch") && e.node_id.as_deref() == Some("m")), "expected deep approval error attributed to the map: {:?}", result.errors ); } #[test] fn map_branch_fan_out_inside_subgraph_errors() { let map = map_node_basic("m", "br", Some("end")); let mut branch = llm_node("br", None, None); branch.next = Some(NextTargets::Many(vec!["x".into(), "y".into()])); let graph = graph_with( vec![ ("m", map), ("br", branch), ("x", llm_node("x", None, None)), ("y", llm_node("y", None, None)), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("'br'") && e.message.contains("fan-out inside a map branch") && e.node_id.as_deref() == Some("m")), "expected fan-out-in-branch error: {:?}", result.errors ); } #[test] fn map_branch_sharing_node_with_main_flow_errors() { let mut start = llm_node("s", None, None); start.next = Some(NextTargets::Many(vec!["m".into(), "shared".into()])); let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("shared")); let shared = llm_with_state_updates("shared", &[("output", "{{output}}")], None); let graph = graph_with( vec![ ("s", start), ("m", map), ("br", branch), ("shared", shared), ("end", end_node("end")), ], "s", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains("main flow") && e.message.contains("[shared]") && e.node_id.as_deref() == Some("m")), "expected shared-node error naming only 'shared': {:?}", result.errors ); } #[test] fn two_maps_sharing_a_subgraph_pass() { let first = map_node_basic("m1", "br", Some("m2")); let second = map_node_basic("m2", "br", Some("end")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![ ("m1", first), ("m2", second), ("br", branch), ("end", end_node("end")), ], "m1", ); let result = validator().validate(&graph); assert!( result.errors.is_empty(), "two maps may share a branch subgraph: {:?}", result.errors ); } fn output_key_warnings(result: &ValidationResult) -> Vec<&ValidationError> { result .warnings .iter() .filter(|w| w.message.contains("declares a write to output_key")) .collect() } #[test] fn map_branch_without_declared_output_writer_warns() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); let warnings = output_key_warnings(&result); assert_eq!(warnings.len(), 1, "{:?}", result.warnings); assert_eq!(warnings[0].node_id.as_deref(), Some("m")); assert!( warnings[0] .message .contains("branch subgraph of map 'm' declares a write to output_key 'output'"), "{}", warnings[0].message ); } #[test] fn map_branch_script_in_subgraph_suppresses_missing_writer_warning() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("finish")); let finish = script_node("finish", "Cargo.toml", None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("finish", finish), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!( output_key_warnings(&result).is_empty(), "a script anywhere in the subgraph may write the key: {:?}", result.warnings ); } #[test] fn map_branch_state_updates_deeper_in_chain_suppress_missing_writer_warning() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("br2")); let br2 = llm_with_state_updates("br2", &[("output", "{{output}}")], None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("br2", br2), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!( output_key_warnings(&result).is_empty(), "state_updates naming output_key anywhere in the chain is a declared write: {:?}", result.warnings ); } #[test] fn map_branch_output_schema_naming_output_key_suppresses_missing_writer_warning() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_output_schema("br", &["output", "notes"], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!( output_key_warnings(&result).is_empty(), "an output_schema property naming output_key is a declared write: {:?}", result.warnings ); } #[test] fn map_branch_three_node_chain_validates_clean() { let map = map_node_basic("m", "a", Some("end")); let a = llm_node("a", None, Some("b")); let b = llm_with_state_updates("b", &[("draft", "{{output}}")], Some("c")); let c = llm_with_state_updates("c", &[("output", "{{draft}}")], None); let graph = graph_with( vec![ ("m", map), ("a", a), ("b", b), ("c", c), ("end", end_node("end")), ], "m", ); let result = validator().validate(&graph); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!( output_key_warnings(&result).is_empty(), "{:?}", result.warnings ); } #[test] fn main_flow_reachable_does_not_enter_map_branches() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_node("br", None, Some("br2")); let br2 = llm_node("br2", Some("end"), None); let graph = graph_with( vec![ ("m", map), ("br", branch), ("br2", br2), ("end", end_node("end")), ], "m", ); assert_eq!(ids(&main_flow_reachable(&graph)), vec!["end", "m"]); assert_eq!( ids(&find_reachable_nodes(&graph)), vec!["br", "br2", "end", "m"] ); } #[test] fn main_flow_reachable_follows_approval_routes() { let gate = approval_node("gate", &["yes"], &[("yes", "m")], "end"); let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![ ("gate", gate), ("m", map), ("br", branch), ("end", end_node("end")), ], "gate", ); assert_eq!(ids(&main_flow_reachable(&graph)), vec!["end", "gate", "m"]); } #[test] fn script_in_fan_out_without_state_updates_errors() { let a = script_node("worker_a", "Cargo.toml", None); let b = end_node("worker_b"); let graph = fan_out_graph_with_two_workers(a, b); let result = validator().validate(&graph); assert!( result.errors.iter().any( |e| e.message.contains("script node is in a parallel branch") && e.message.contains("no `state_updates`") && e.node_id.as_deref() == Some("worker_a") ), "expected script-no-state-updates-in-parallel error: {:?}", result.errors ); } #[test] fn script_in_fan_out_with_state_updates_passes() { let a = script_with_state_updates("worker_a", &[("result_a", "{{output.x}}")]); let b = end_node("worker_b"); let graph = fan_out_graph_with_two_workers(a, b); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("script node is in a parallel branch")), "script with state_updates should pass C.6: {:?}", result.errors ); } #[test] fn script_outside_fan_out_without_state_updates_passes() { let mut start = end_node("start"); start.next = Some("s".into()); let s = script_node("s", "Cargo.toml", None); let graph = graph_with( vec![("start", start), ("s", s), ("end", end_node("end"))], "start", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("parallel branch")), "script outside fan-out should not trigger C.6: {:?}", result.errors ); } #[test] fn settings_max_concurrency_zero_errors() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.settings.max_concurrency = 0; let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("settings.max_concurrency must be >= 1")), "expected graph-level max_concurrency=0 error: {:?}", result.errors ); } #[test] fn settings_max_concurrency_above_semaphore_limit_errors() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.settings.max_concurrency = Semaphore::MAX_PERMITS + 1; let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message.contains(&format!( "settings.max_concurrency must be <= {} (got {})", Semaphore::MAX_PERMITS, Semaphore::MAX_PERMITS + 1 ))), "expected graph-level max_concurrency upper-bound error: {:?}", result.errors ); } #[test] fn settings_max_concurrency_default_is_valid() { let graph = graph_with(vec![("e", end_node("e"))], "e"); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("max_concurrency")), "default max_concurrency should not error: {:?}", result.errors ); } #[test] fn zero_max_agent_depth_warns() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.max_agent_depth = Some(0); let result = validator().validate(&graph); assert!(result.is_valid()); assert!( result .warnings .iter() .any(|w| w.message.contains("`max_agent_depth: 0`")), "expected max_agent_depth=0 warning: {:?}", result.warnings ); } #[test] fn zero_max_concurrent_agents_warns() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.max_concurrent_agents = Some(0); let result = validator().validate(&graph); assert!(result.is_valid()); assert!( result .warnings .iter() .any(|w| w.message.contains("`max_concurrent_agents: 0`")), "expected max_concurrent_agents=0 warning: {:?}", result.warnings ); } #[test] fn unset_orchestration_limits_do_not_warn() { let graph = graph_with(vec![("e", end_node("e"))], "e"); let result = validator().validate(&graph); assert!( !result.warnings.iter().any(|w| { w.message.contains("max_agent_depth") || w.message.contains("max_concurrent_agents") }), "unset limits should not warn: {:?}", result.warnings ); } fn variable(name: &str) -> crate::config::AgentVariable { crate::config::AgentVariable { name: name.into(), ..Default::default() } } fn shadowing_warnings(result: &ValidationResult) -> Vec<&ValidationError> { result .warnings .iter() .filter(|w| w.message.contains("shadowed")) .collect() } #[test] fn variable_shadowed_by_initial_state_key_warns() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.variables = vec![variable("region")]; graph .initial_state .insert("region".into(), serde_json::json!("us-east-1")); let result = validator().validate(&graph); assert!(result.is_valid()); let w = shadowing_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id, None); assert!(w[0].message.contains("'region'"), "{}", w[0].message); } #[test] fn disjoint_variables_and_initial_state_do_not_warn() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.variables = vec![variable("region")]; graph .initial_state .insert("zone".into(), serde_json::json!("a")); let result = validator().validate(&graph); assert!( shadowing_warnings(&result).is_empty(), "disjoint names should not warn: {:?}", result.warnings ); } #[test] fn each_shadowed_variable_warns_once() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.variables = vec![variable("region"), variable("tier")]; graph .initial_state .insert("region".into(), serde_json::json!("us")); graph .initial_state .insert("tier".into(), serde_json::json!("gold")); let result = validator().validate(&graph); let w = shadowing_warnings(&result); assert_eq!(w.len(), 2, "{:?}", result.warnings); } fn timeout_warnings(result: &ValidationResult) -> Vec<&ValidationError> { result .warnings .iter() .filter(|w| w.message.contains("disables the")) .collect() } #[test] fn settings_timeout_zero_warns() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.settings.timeout = Some(0); let result = validator().validate(&graph); assert!(result.is_valid()); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id, None); assert_eq!( w[0].message, "settings.timeout: 0 disables the graph wall-clock bound; the run ends only when \ the graph completes, is aborted, or another limit stops it" ); } #[test] fn agent_timeout_zero_warns() { let mut a = agent_node("a", "worker", Some("end")); if let NodeType::Agent(ref mut an) = a.node_type { an.timeout = Some(0); } let graph = graph_with(vec![("a", a), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id.as_deref(), Some("a")); assert_eq!( w[0].message, "timeout: 0 disables the wall-clock bound for agent node 'a'; it runs until it returns \ or an enclosing bound (settings.timeout, an outer agent node's timeout, or abort) ends it" ); } #[test] fn script_timeout_zero_warns() { let mut s = script_node("s", "does-not-exist.py", None); if let NodeType::Script(ref mut sn) = s.node_type { sn.timeout = 0; } s.next = Some("end".into()); let graph = graph_with(vec![("s", s), ("end", end_node("end"))], "s"); let result = validator().validate(&graph); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id.as_deref(), Some("s")); assert_eq!( w[0].message, "timeout: 0 disables the wall-clock bound for script node 's'; it runs until it returns \ or an enclosing bound (settings.timeout, an outer agent node's timeout, or abort) ends it" ); } #[test] fn llm_timeout_zero_warns() { let mut l = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut ln) = l.node_type { ln.timeout = Some(0); } let graph = graph_with(vec![("l", l), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id.as_deref(), Some("l")); assert_eq!( w[0].message, "timeout: 0 disables the wall-clock bound for llm node 'l'; it runs until it returns \ or an enclosing bound (settings.timeout, an outer agent node's timeout, or abort) ends it" ); } #[test] fn rag_timeout_zero_warns() { let mut r = rag_node("r", &["docs/"], true); if let NodeType::Rag(ref mut rn) = r.node_type { rn.timeout = Some(0); } let graph = graph_with(vec![("r", r), ("end", end_node("end"))], "r"); let result = validator().validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id.as_deref(), Some("r")); assert_eq!( w[0].message, "timeout: 0 disables the wall-clock bound for rag node 'r'; it runs until it returns \ or an enclosing bound (settings.timeout, an outer agent node's timeout, or abort) ends it" ); } #[test] fn nonzero_and_unset_timeouts_do_not_warn() { let mut a = agent_node("a", "worker", Some("s")); if let NodeType::Agent(ref mut an) = a.node_type { an.timeout = Some(600); } let mut s = script_node("s", "does-not-exist.py", None); s.next = Some("l".into()); let l = llm_node("l", None, Some("end")); let mut graph = graph_with( vec![("a", a), ("s", s), ("l", l), ("end", end_node("end"))], "a", ); graph.settings.timeout = Some(30); let result = validator().validate(&graph); assert!( timeout_warnings(&result).is_empty(), "non-zero and unset timeouts should not warn: {:?}", result.warnings ); } #[test] fn max_loop_iterations_zero_warns() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.settings.max_loop_iterations = 0; let result = validator().validate(&graph); assert!(result.is_valid()); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert!(w[0].node_id.is_none()); assert_eq!( w[0].message, "settings.max_loop_iterations: 0 disables the per-node visit cap; a non-converging \ loop runs until an enclosing timeout or abort" ); } #[test] fn max_loop_iterations_nonzero_does_not_warn() { let mut graph = graph_with(vec![("e", end_node("e"))], "e"); graph.settings.max_loop_iterations = 5; let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|w| w.message.contains("max_loop_iterations: 0")), "non-zero cap should not warn: {:?}", result.warnings ); } #[test] fn llm_max_iterations_zero_warns() { let mut l = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut ln) = l.node_type { ln.max_iterations = 0; } let graph = graph_with(vec![("l", l), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); let w = timeout_warnings(&result); assert_eq!(w.len(), 1, "{:?}", result.warnings); assert_eq!(w[0].node_id.as_deref(), Some("l")); assert_eq!( w[0].message, "max_iterations: 0 disables the turn cap for llm node 'l'; it runs until the model \ concludes, the context window fills, an enclosing timeout, or abort" ); } #[test] fn llm_max_iterations_nonzero_does_not_warn() { let graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|w| w.message.contains("max_iterations: 0")), "non-zero cap should not warn: {:?}", result.warnings ); } #[test] fn llm_zero_cap_and_zero_timeout_warn_in_order() { let mut l = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut ln) = l.node_type { ln.max_iterations = 0; ln.timeout = Some(0); } let graph = graph_with(vec![("l", l), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!(result.is_valid(), "errors: {:?}", result.errors); let w = timeout_warnings(&result); assert_eq!(w.len(), 2, "{:?}", result.warnings); assert!( w[0].message .starts_with("max_iterations: 0 disables the turn cap") ); assert!( w[1].message .starts_with("timeout: 0 disables the wall-clock bound") ); assert_eq!(w[0].node_id.as_deref(), Some("l")); assert_eq!(w[1].node_id.as_deref(), Some("l")); } #[test] fn map_max_concurrency_above_semaphore_limit_errors() { let mut map = map_node_basic("m", "br", Some("end")); if let NodeType::Map(ref mut mm) = map.node_type { mm.max_concurrency = Some(ConcurrencyCap::Fixed(Semaphore::MAX_PERMITS + 1)); } let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .contains(&format!("must be <= {}", Semaphore::MAX_PERMITS)) && e.node_id.as_deref() == Some("m")), "expected map max_concurrency upper-bound error: {:?}", result.errors ); } #[test] fn map_max_concurrency_zero_errors() { let mut map = map_node_basic("m", "br", Some("end")); if let NodeType::Map(ref mut mm) = map.node_type { mm.max_concurrency = Some(ConcurrencyCap::Fixed(0)); } let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .contains("map node's `max_concurrency` must be >= 1") && e.node_id.as_deref() == Some("m")), "expected map max_concurrency=0 error: {:?}", result.errors ); } #[test] fn map_max_concurrency_none_is_valid() { let map = map_node_basic("m", "br", Some("end")); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("max_concurrency")), "map without max_concurrency should not error: {:?}", result.errors ); } fn map_with_cap(id: &str, branch: &str, next: Option<&str>, cap: &str) -> Node { let mut map = map_node_basic(id, branch, next); if let NodeType::Map(ref mut mm) = map.node_type { mm.max_concurrency = Some(ConcurrencyCap::Template(cap.into())); } map } fn map_with_as(id: &str, branch: &str, next: Option<&str>, as_name: &str) -> Node { let mut map = map_node_basic(id, branch, next); if let NodeType::Map(ref mut mm) = map.node_type { mm.as_name = as_name.into(); } map } #[test] fn map_as_equal_output_key_errors() { let map = map_with_as("m", "br", Some("end"), "output"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message == "map node 'm': `as` and `output_key` are both 'output'; the item binding \ would be collected as the chain's result" && e.node_id.as_deref() == Some("m")), "expected as/output_key collision error: {:?}", result.errors ); } #[test] fn map_as_distinct_from_output_key_passes() { let map = map_with_as("m", "br", Some("end"), "item"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("`as` and `output_key`")), "distinct as/output_key must not error: {:?}", result.errors ); } #[test] fn map_max_concurrency_non_template_string_errors() { let map = map_with_cap("m", "br", Some("end"), "4"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .contains("`max_concurrency` is a string but not a template") && e.node_id.as_deref() == Some("m")), "expected non-template string cap error: {:?}", result.errors ); } #[test] fn map_max_concurrency_template_string_is_valid() { let map = map_with_cap("m", "br", Some("end"), "{{k}}"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("max_concurrency")), "templated cap should not error: {:?}", result.errors ); } #[test] fn map_max_concurrency_unclosed_template_errors() { let map = map_with_cap("m", "br", Some("end"), "{{k"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("is a string but not a template")), "expected unclosed-template cap error: {:?}", result.errors ); } #[test] fn map_max_concurrency_malformed_templates_error() { for cap in ["{{}}", "{{ key }}", "n={{k}}", "{{a}} {{b", "{{a-b}}"] { let map = map_with_cap("m", "br", Some("end"), cap); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message == "map node's `max_concurrency` is a string but not a template; write \ an integer or exactly one `{{key}}` with nothing around it" && e.node_id.as_deref() == Some("m")), "cap {cap:?} should fail the whole-string template check: {:?}", result.errors ); } } #[test] fn map_max_concurrency_non_numeric_index_errors() { let map = map_with_cap("m", "br", Some("end"), "{{limits[foo]}}"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e.message == "map node's `max_concurrency` is a string but not a template; write \ an integer or exactly one `{{key}}` with nothing around it" && e.node_id.as_deref() == Some("m")), "a non-numeric index can never resolve and must fail at load: {:?}", result.errors ); } #[test] fn map_max_concurrency_lone_template_forms_pass() { for cap in ["{{budget}}", "{{cfg.limits[0]}}", " {{budget}} "] { let map = map_with_cap("m", "br", Some("end"), cap); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let graph = graph_with( vec![("m", map), ("br", branch), ("end", end_node("end"))], "m", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("is a string but not a template")), "cap {cap:?} is a lone template and must pass: {:?}", result.errors ); } } #[test] fn map_max_concurrency_template_reading_sibling_write_errors() { let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["m".into(), "worker_b".into()])); let map = map_with_cap("m", "br", Some("end"), "{{summary}}"); let branch = llm_with_state_updates("br", &[("output", "{{output}}")], None); let writer = llm_with_state_updates("worker_b", &[("summary", "static")], Some("end")); let graph = graph_with( vec![ ("start", start), ("m", map), ("br", branch), ("worker_b", writer), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("reads state key(s) `summary`") && e.message.contains("'worker_b'") && e.node_id.as_deref() == Some("m")), "expected cross-branch read error for templated cap: {:?}", result.errors ); } fn llm_with_prompt(id: &str, prompt: &str, next: Option<&str>) -> Node { let mut node = llm_node(id, None, next); if let NodeType::Llm(ref mut n) = node.node_type { n.prompt = prompt.into(); } node } #[test] fn parallel_read_of_sibling_write_errors() { let reader = llm_with_prompt("worker_a", "Hello {{summary}}!", Some("end")); let writer = llm_with_state_updates("worker_b", &[("summary", "static")], Some("end")); let graph = fan_out_graph_with_two_workers(reader, writer); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("reads state key(s) `summary`") && e.message.contains("'worker_b'")), "expected cross-branch read error mentioning `summary` and sibling writer: {:?}", result.errors ); } #[test] fn parallel_read_of_upstream_key_passes() { let reader_a = llm_with_prompt("worker_a", "Topic is {{topic}}", Some("end")); let reader_b = llm_with_prompt("worker_b", "Also {{topic}}", Some("end")); let graph = fan_out_graph_with_two_workers(reader_a, reader_b); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("reads state key")), "upstream `topic` shouldn't trigger cross-branch read error: {:?}", result.errors ); } #[test] fn scoped_output_var_in_state_updates_not_treated_as_read() { let scoped_user = llm_with_state_updates("worker_a", &[("a_key", "{{output}}")], Some("end")); let writes_output = llm_with_state_updates("worker_b", &[("output", "{{output}}")], Some("end")); let graph = fan_out_graph_with_two_workers(scoped_user, writes_output); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("reads state key(s) `output`") && e.message.contains("worker_a")), "scoped `{{{{output}}}}` inside state_updates value should NOT be treated as a read: {:?}", result.errors ); } #[test] fn rag_query_reading_sibling_script_write_errors() { let mut rag = rag_node("worker_a", &["./k"], true); if let NodeType::Rag(ref mut n) = rag.node_type { n.query = Some("codes: {{loinc_codes}}\n{{db_result}}".into()); if let Some(m) = n.state_updates.as_mut() { m.insert("rag_ctx".into(), "{{output.context}}".into()); } } rag.next = Some("end".into()); let mut script = script_with_state_updates("worker_b", &[("db_result", "{{output}}")]); script.next = Some("end".into()); let graph = fan_out_graph_with_two_workers(rag, script); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("reads state key(s) `db_result`") && e.message.contains("'worker_b'")), "expected cross-branch read error for rag query reading db_result: {:?}", result.errors ); } #[test] fn map_over_reading_sibling_write_errors() { let map_n = Node { id: "fan".into(), description: String::new(), node_type: NodeType::Map(MapNode { over: "{{items}}".into(), as_name: "item".into(), branch: "branch_n".into(), output_key: "output".into(), collect_into: "results".into(), max_concurrency: None, }), next: Some("end".into()), }; let branch_n = llm_with_prompt("branch_n", "Process {{item}}", None); let producer = llm_with_state_updates("producer", &[("items", "[1,2,3]")], Some("end")); let mut start = end_node("start"); start.next = Some(NextTargets::Many(vec!["fan".into(), "producer".into()])); let graph = graph_with( vec![ ("start", start), ("fan", map_n), ("branch_n", branch_n), ("producer", producer), ("end", end_node("end")), ], "start", ); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("reads state key(s) `items`") && e.message.contains("'producer'")), "expected cross-branch read error for map `over` reading sibling write: {:?}", result.errors ); } struct TestConfigDirGuard { key: String, previous: Option, path: PathBuf, } impl TestConfigDirGuard { fn new() -> Self { let key = get_env_name("config_dir"); let previous = env::var_os(&key); let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = env::temp_dir().join(format!("coyote-graph-validator-tests-{unique}")); fs::create_dir_all(&path).unwrap(); unsafe { env::set_var(&key, &path); } Self { key, previous, path, } } } impl Drop for TestConfigDirGuard { fn drop(&mut self) { if let Some(previous) = &self.previous { unsafe { env::set_var(&self.key, previous); } } else { unsafe { env::remove_var(&self.key); } } let _ = fs::remove_dir_all(&self.path); } } fn materialize_config_only_agent(name: &str) { fs::create_dir_all(paths::agent_data_dir(name)).unwrap(); fs::write(paths::agent_config_file(name), "").unwrap(); } fn materialize_graph_agent(name: &str) { fs::create_dir_all(paths::agent_data_dir(name)).unwrap(); fs::write(paths::agent_graph_file(name), "").unwrap(); } fn agent_node_with_inputs(id: &str, agent: &str, inputs: &[(&str, &str)]) -> Node { let mut node = agent_node(id, agent, Some("end")); if let NodeType::Agent(ref mut a) = node.node_type { a.inputs = Some( inputs .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(), ); } node } fn inputs_requires_graph_agent_error(result: &ValidationResult, node_id: &str) -> bool { result.errors.iter().any(|e| { e.message.contains("`inputs:` requires agent") && e.message .contains("to be a graph agent (no graph.yaml found)") && e.node_id.as_deref() == Some(node_id) }) } #[test] #[serial] fn agent_inputs_on_config_only_agent_errors() { let _guard = TestConfigDirGuard::new(); materialize_config_only_agent("plain-agent"); let node = agent_node_with_inputs("a", "plain-agent", &[("x", "{{k}}")]); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| { e.message == "agent node 'a': `inputs:` requires agent 'plain-agent' to be a graph agent (no graph.yaml found)" && e.node_id.as_deref() == Some("a") }), "{:?}", result.errors ); } #[test] #[serial] fn agent_empty_inputs_on_config_only_agent_still_errors() { let _guard = TestConfigDirGuard::new(); materialize_config_only_agent("plain-agent"); let node = agent_node_with_inputs("a", "plain-agent", &[]); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( inputs_requires_graph_agent_error(&result, "a"), "{:?}", result.errors ); } #[test] #[serial] fn agent_inputs_on_graph_agent_passes() { let _guard = TestConfigDirGuard::new(); materialize_graph_agent("graph-agent"); let node = agent_node_with_inputs("a", "graph-agent", &[("x", "{{k}}")]); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!(result.is_valid(), "{:?}", result.errors); assert!(!inputs_requires_graph_agent_error(&result, "a")); } #[test] #[serial] fn agent_with_both_config_and_graph_errors() { let _guard = TestConfigDirGuard::new(); materialize_config_only_agent("mixed-agent"); materialize_graph_agent("mixed-agent"); let node = agent_node_with_inputs("a", "mixed-agent", &[("x", "{{k}}")]); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| e .message .starts_with("Agent 'mixed-agent' has both config.yaml and graph.yaml") && e.node_id.as_deref() == Some("a")), "{:?}", result.errors ); } #[test] #[serial] fn agent_inputs_missing_agent_dir_gets_only_the_not_found_error() { let _guard = TestConfigDirGuard::new(); let node = agent_node_with_inputs("a", "ghost-agent", &[("x", "{{k}}")]); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("Agent 'ghost-agent' not found")), "{:?}", result.errors ); assert!(!inputs_requires_graph_agent_error(&result, "a")); } #[test] #[serial] fn agent_inputs_initial_prompt_is_reserved() { let _guard = TestConfigDirGuard::new(); materialize_graph_agent("graph-agent"); let node = agent_node_with_inputs( "a", "graph-agent", &[("initial_prompt", "{{k}}"), ("x", "{{k}}")], ); let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( result.errors.iter().any(|e| { e.message == "agent node 'a': `inputs.initial_prompt` is reserved (the dispatcher seeds it from `prompt:`)" && e.node_id.as_deref() == Some("a") }), "{:?}", result.errors ); assert!(!inputs_requires_graph_agent_error(&result, "a")); } #[test] fn agent_inputs_reading_sibling_write_errors() { let reader = agent_node_with_inputs("worker_a", "some-agent", &[("x", "{{k}}")]); let writer = llm_with_state_updates("worker_b", &[("k", "static")], Some("end")); let graph = fan_out_graph_with_two_workers(reader, writer); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.message.contains("reads state key(s) `k`") && e.message.contains("'worker_b'") && e.node_id.as_deref() == Some("worker_a")), "expected cross-branch read error for agent inputs: {:?}", result.errors ); } #[test] fn agent_inputs_reading_upstream_key_passes() { let reader = agent_node_with_inputs("worker_a", "some-agent", &[("x", "{{k}}")]); let writer = llm_with_state_updates("worker_b", &[("other", "static")], Some("end")); let graph = fan_out_graph_with_two_workers(reader, writer); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("reads state key")), "upstream `k` shouldn't trigger cross-branch read error: {:?}", result.errors ); } fn ids(subgraph: &HashSet) -> Vec<&str> { let mut ids: Vec<&str> = subgraph.iter().map(String::as_str).collect(); ids.sort_unstable(); ids } #[test] fn branch_subgraph_follows_next_and_fallback_edges() { let mut a = script_node("a", "a.sh", Some("c")); a.next = Some("b".to_string().into()); let graph = graph_with( vec![ ("a", a), ("b", llm_node("b", Some("d"), None)), ("c", script_node("c", "c.sh", None)), ("d", script_node("d", "d.sh", None)), ("e", script_node("e", "e.sh", None)), ], "a", ); assert_eq!(ids(&branch_subgraph(&graph, "a")), vec!["a", "b", "c", "d"]); } #[test] fn branch_subgraph_does_not_follow_approval_routes_or_nested_map_branch() { let mut a = script_node("a", "a.sh", None); a.next = Some(NextTargets::Many(vec!["m".into(), "p".into()])); let graph = graph_with( vec![ ("a", a), ("m", map_node_basic("m", "inner", None)), ("inner", script_node("inner", "inner.sh", None)), ("p", approval_node("p", &["yes"], &[("yes", "q")], "q")), ("q", script_node("q", "q.sh", None)), ], "a", ); assert_eq!(ids(&branch_subgraph(&graph, "a")), vec!["a", "m", "p"]); } #[test] fn branch_subgraph_of_unknown_entry_is_empty() { let graph = graph_with(vec![("a", script_node("a", "a.sh", None))], "a"); assert!(branch_subgraph(&graph, "nope").is_empty()); } #[test] fn branch_subgraph_skips_undeclared_targets() { let mut a = script_node("a", "a.sh", Some("ghost_fallback")); a.next = Some("ghost_next".to_string().into()); let graph = graph_with(vec![("a", a)], "a"); assert_eq!(ids(&branch_subgraph(&graph, "a")), vec!["a"]); } #[test] fn llm_max_attempts_zero_is_an_error() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.max_attempts = 0; } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message.contains("`max_attempts` must be >= 1")), "expected max_attempts error: {:?}", result.errors ); } #[test] fn agent_max_attempts_zero_is_an_error() { let mut node = agent_node("a", "helper", Some("end")); if let NodeType::Agent(ref mut n) = node.node_type { n.max_attempts = 0; } let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.node_id.as_deref() == Some("a") && e.message.contains("`max_attempts` must be >= 1")), "expected max_attempts error: {:?}", result.errors ); } #[test] fn max_attempts_of_one_is_not_flagged() { let graph = graph_with( vec![ ("l", llm_node("l", None, Some("end"))), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("`max_attempts`")), "unexpected max_attempts error: {:?}", result.errors ); } #[test] fn llm_fallback_with_output_schema_and_no_state_updates_warns() { let mut node = llm_with_output_schema("l", &["summary"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.fallback = Some("fb".into()); } let graph = graph_with( vec![ ("l", node), ("fb", end_node("fb")), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( result .warnings .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message .contains("declares `fallback` with `output_schema`")), "expected fallback capture warning: {:?}", result.warnings ); } #[test] fn agent_fallback_with_output_schema_and_no_state_updates_warns() { let mut node = agent_node("a", "helper", Some("end")); if let NodeType::Agent(ref mut n) = node.node_type { n.fallback = Some("fb".into()); n.output_schema = Some(serde_json::json!({ "type": "object" })); } let graph = graph_with( vec![ ("a", node), ("fb", end_node("fb")), ("end", end_node("end")), ], "a", ); let result = validator().validate(&graph); assert!( result .warnings .iter() .any(|e| e.node_id.as_deref() == Some("a") && e.message .contains("declares `fallback` with `output_schema`")), "expected fallback capture warning: {:?}", result.warnings ); } #[test] fn fallback_with_output_schema_and_state_updates_is_not_flagged() { let mut node = llm_with_output_schema("l", &["summary"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.fallback = Some("fb".into()); n.state_updates = Some(HashMap::from([("summary".into(), "{{output}}".into())])); } let graph = graph_with( vec![ ("l", node), ("fb", end_node("fb")), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( !result.warnings.iter().any(|e| e .message .contains("declares `fallback` with `output_schema`")), "unexpected fallback capture warning: {:?}", result.warnings ); } #[test] fn structured_output_node_with_user_tools_warns() { let mut node = llm_with_output_schema("l", &["verdict"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["user__select".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( result .warnings .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message.contains("interactive user tools")), "expected user-tool warning: {:?}", result.warnings ); } #[test] fn structured_output_node_with_all_tools_does_not_warn() { // `all` is not a valid llm-node tools entry (validate_tools_subset // rejects it at run()), so this advisory stays silent about it. let mut node = llm_with_output_schema("l", &["verdict"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["all".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|e| e.message.contains("interactive user tools")), "unexpected user-tool warning: {:?}", result.warnings ); } #[test] fn structured_output_node_without_user_tools_does_not_warn() { let mut node = llm_with_output_schema("l", &["verdict"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec![]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|e| e.message.contains("interactive user tools")), "unexpected user-tool warning: {:?}", result.warnings ); } #[test] fn non_structured_node_with_user_tools_does_not_warn() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["user__select".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( !result .warnings .iter() .any(|e| e.message.contains("interactive user tools")), "unexpected user-tool warning: {:?}", result.warnings ); } #[test] fn structured_output_node_with_todo_tools_errors() { let mut node = llm_with_output_schema("l", &["verdict"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["todo__init".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message.contains("todo tools drive chat turn loops")), "expected todo-tool error: {:?}", result.errors ); } #[test] fn non_structured_node_with_todo_tools_errors() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["todo__pause".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( result .errors .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message.contains("todo tools drive chat turn loops")), "expected todo-tool error: {:?}", result.errors ); } #[test] fn llm_node_without_todo_tools_does_not_error() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.tools = Some(vec!["user__select".into()]); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( !result .errors .iter() .any(|e| e.message.contains("todo tools drive chat turn loops")), "unexpected todo-tool error: {:?}", result.errors ); } #[test] fn fallback_with_output_schema_and_empty_state_updates_warns() { let mut node = llm_with_output_schema("l", &["summary"], Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.fallback = Some("fb".into()); n.state_updates = Some(HashMap::new()); } let graph = graph_with( vec![ ("l", node), ("fb", end_node("fb")), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( result .warnings .iter() .any(|e| e.node_id.as_deref() == Some("l") && e.message .contains("declares `fallback` with `output_schema`")), "expected fallback capture warning: {:?}", result.warnings ); } #[test] fn output_schema_without_fallback_is_not_flagged() { let graph = graph_with( vec![ ("l", llm_with_output_schema("l", &["summary"], Some("end"))), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( !result.warnings.iter().any(|e| e .message .contains("declares `fallback` with `output_schema`")), "unexpected fallback capture warning: {:?}", result.warnings ); } fn bare_object_schema_warnings(result: &ValidationResult) -> Vec<&ValidationError> { result .warnings .iter() .filter(|e| { e.message .contains("declares no (non-empty) object `properties`") }) .collect() } #[test] fn llm_output_schema_without_properties_warns() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.output_schema = Some(serde_json::json!({ "type": "object" })); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); let warnings = bare_object_schema_warnings(&result); assert_eq!(warnings.len(), 1, "{:?}", result.warnings); assert_eq!(warnings[0].node_id.as_deref(), Some("l")); assert!(warnings[0].message.starts_with("llm node's")); } #[test] fn agent_output_schema_without_properties_warns() { let mut node = agent_node("a", "helper", Some("end")); if let NodeType::Agent(ref mut n) = node.node_type { n.output_schema = Some(serde_json::json!({ "type": "object" })); } let graph = graph_with(vec![("a", node), ("end", end_node("end"))], "a"); let result = validator().validate(&graph); let warnings = bare_object_schema_warnings(&result); assert_eq!(warnings.len(), 1, "{:?}", result.warnings); assert_eq!(warnings[0].node_id.as_deref(), Some("a")); assert!(warnings[0].message.starts_with("agent node's")); } #[test] fn output_schema_with_empty_or_non_object_properties_warns() { for properties in [serde_json::json!({}), serde_json::json!([])] { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.output_schema = Some(serde_json::json!({ "type": "object", "properties": properties, })); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); let warnings = bare_object_schema_warnings(&result); assert_eq!( warnings.len(), 1, "properties={properties}: {:?}", result.warnings ); assert_eq!(warnings[0].node_id.as_deref(), Some("l")); } } #[test] fn bare_output_schema_with_state_updates_is_not_flagged() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.output_schema = Some(serde_json::json!({ "type": "object" })); n.state_updates = Some(HashMap::from([("k".into(), "{{output}}".into())])); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); assert!( bare_object_schema_warnings(&result).is_empty(), "{:?}", result.warnings ); } #[test] fn bare_output_schema_with_empty_state_updates_still_warns() { let mut node = llm_node("l", None, Some("end")); if let NodeType::Llm(ref mut n) = node.node_type { n.output_schema = Some(serde_json::json!({ "type": "object" })); n.state_updates = Some(HashMap::new()); } let graph = graph_with(vec![("l", node), ("end", end_node("end"))], "l"); let result = validator().validate(&graph); let warnings = bare_object_schema_warnings(&result); assert_eq!(warnings.len(), 1, "{:?}", result.warnings); assert_eq!(warnings[0].node_id.as_deref(), Some("l")); } #[test] fn output_schema_with_properties_or_absent_is_not_flagged() { let graph = graph_with( vec![ ("l", llm_with_output_schema("l", &["k"], Some("plain"))), ("plain", llm_node("plain", None, Some("end"))), ("end", end_node("end")), ], "l", ); let result = validator().validate(&graph); assert!( bare_object_schema_warnings(&result).is_empty(), "{:?}", result.warnings ); } }