use crate::schema::{AgentSchema, AgentType, END, ProjectSchema, START, ToolConfig};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct ValidationError {
pub code: ValidationErrorCode,
pub message: String,
pub context: Option<String>,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ctx) = &self.context {
write!(f, "[{}] {}: {}", self.code, ctx, self.message)
} else {
write!(f, "[{}] {}", self.code, self.message)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationErrorCode {
NoAgents,
NoEdges,
MissingStartEdge,
MissingEndEdge,
DisconnectedNode,
MissingRequiredField,
InvalidToolConfig,
InvalidRouteConfig,
CircularDependency,
InvalidSubAgentRef,
}
impl std::fmt::Display for ValidationErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoAgents => write!(f, "NO_AGENTS"),
Self::NoEdges => write!(f, "NO_EDGES"),
Self::MissingStartEdge => write!(f, "MISSING_START"),
Self::MissingEndEdge => write!(f, "MISSING_END"),
Self::DisconnectedNode => write!(f, "DISCONNECTED"),
Self::MissingRequiredField => write!(f, "MISSING_FIELD"),
Self::InvalidToolConfig => write!(f, "INVALID_TOOL"),
Self::InvalidRouteConfig => write!(f, "INVALID_ROUTE"),
Self::CircularDependency => write!(f, "CIRCULAR_DEP"),
Self::InvalidSubAgentRef => write!(f, "INVALID_SUBAGENT"),
}
}
}
#[derive(Debug)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationError>,
}
impl ValidationResult {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn add_error(&mut self, code: ValidationErrorCode, message: impl Into<String>) {
self.errors.push(ValidationError {
code,
message: message.into(),
context: None,
});
}
pub fn add_error_with_context(
&mut self,
code: ValidationErrorCode,
message: impl Into<String>,
context: impl Into<String>,
) {
self.errors.push(ValidationError {
code,
message: message.into(),
context: Some(context.into()),
});
}
pub fn add_warning(&mut self, code: ValidationErrorCode, message: impl Into<String>) {
self.warnings.push(ValidationError {
code,
message: message.into(),
context: None,
});
}
pub fn add_warning_with_context(
&mut self,
code: ValidationErrorCode,
message: impl Into<String>,
context: impl Into<String>,
) {
self.warnings.push(ValidationError {
code,
message: message.into(),
context: Some(context.into()),
});
}
}
impl Default for ValidationResult {
fn default() -> Self {
Self::new()
}
}
pub fn validate_project(project: &ProjectSchema) -> ValidationResult {
let mut result = ValidationResult::new();
validate_not_empty(project, &mut result);
if !result.is_valid() {
return result;
}
validate_graph_connectivity(project, &mut result);
for (agent_id, agent) in &project.agents {
validate_agent(agent_id, agent, project, &mut result);
}
validate_tool_configs(project, &mut result);
result
}
fn validate_not_empty(project: &ProjectSchema, result: &mut ValidationResult) {
let has_agents = !project.agents.is_empty();
let has_action_nodes = !project.action_nodes.is_empty();
if !has_agents && !has_action_nodes {
result.add_error(
ValidationErrorCode::NoAgents,
"Project must have at least one agent or action node",
);
}
if project.workflow.edges.is_empty() {
result.add_error(
ValidationErrorCode::NoEdges,
"Workflow must have at least one edge",
);
}
}
fn validate_graph_connectivity(project: &ProjectSchema, result: &mut ValidationResult) {
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
let mut has_start_edge = false;
let mut has_end_edge = false;
for edge in &project.workflow.edges {
if edge.from == START {
has_start_edge = true;
}
if edge.to == END {
has_end_edge = true;
}
adjacency
.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
if !has_start_edge {
result.add_error(
ValidationErrorCode::MissingStartEdge,
"Workflow must have an edge from START",
);
}
if !has_end_edge {
result.add_error(
ValidationErrorCode::MissingEndEdge,
"Workflow must have an edge to END",
);
}
let all_sub_agents: HashSet<_> = project
.agents
.values()
.flat_map(|a| a.sub_agents.iter().map(|s| s.as_str()))
.collect();
let top_level_agents: HashSet<_> = project
.agents
.keys()
.filter(|id| !all_sub_agents.contains(id.as_str()))
.collect();
let mut reachable: HashSet<&str> = HashSet::new();
let mut queue: Vec<&str> = vec![START];
while let Some(node) = queue.pop() {
if reachable.contains(node) {
continue;
}
reachable.insert(node);
if let Some(neighbors) = adjacency.get(node) {
for neighbor in neighbors {
if !reachable.contains(neighbor) {
queue.push(neighbor);
}
}
}
}
for agent_id in &top_level_agents {
let reachable_through_action = project.action_nodes.keys().any(|action_id| {
reachable.contains(action_id.as_str())
&& project
.workflow
.edges
.iter()
.any(|e| e.from == *action_id && e.to == **agent_id)
});
if !reachable.contains(agent_id.as_str()) && !reachable_through_action {
result.add_error_with_context(
ValidationErrorCode::DisconnectedNode,
"Agent is not reachable from START",
agent_id.as_str(),
);
}
}
}
fn validate_agent(
agent_id: &str,
agent: &AgentSchema,
project: &ProjectSchema,
result: &mut ValidationResult,
) {
match agent.agent_type {
AgentType::Llm => validate_llm_agent(agent_id, agent, result),
AgentType::Router => validate_router_agent(agent_id, agent, project, result),
AgentType::Sequential | AgentType::Loop | AgentType::Parallel => {
validate_container_agent(agent_id, agent, project, result)
}
_ => {}
}
}
fn validate_llm_agent(agent_id: &str, agent: &AgentSchema, result: &mut ValidationResult) {
if agent.model.is_none() {
result.add_warning_with_context(
ValidationErrorCode::MissingRequiredField,
"LLM agent has no model specified, will use default",
agent_id,
);
}
if agent.instruction.trim().is_empty() {
result.add_warning_with_context(
ValidationErrorCode::MissingRequiredField,
"LLM agent has no instruction, behavior may be unpredictable",
agent_id,
);
}
}
fn validate_router_agent(
agent_id: &str,
agent: &AgentSchema,
project: &ProjectSchema,
result: &mut ValidationResult,
) {
if agent.routes.is_empty() {
result.add_error_with_context(
ValidationErrorCode::InvalidRouteConfig,
"Router agent must have at least one route defined",
agent_id,
);
return;
}
for route in &agent.routes {
if route.target != END && !project.agents.contains_key(&route.target) {
result.add_error_with_context(
ValidationErrorCode::InvalidRouteConfig,
format!("Route target '{}' does not exist", route.target),
agent_id,
);
}
if route.condition.trim().is_empty() {
result.add_error_with_context(
ValidationErrorCode::InvalidRouteConfig,
"Route condition cannot be empty",
agent_id,
);
}
}
}
fn validate_container_agent(
agent_id: &str,
agent: &AgentSchema,
project: &ProjectSchema,
result: &mut ValidationResult,
) {
if agent.sub_agents.is_empty() {
result.add_error_with_context(
ValidationErrorCode::MissingRequiredField,
format!(
"{:?} agent must have at least one sub-agent",
agent.agent_type
),
agent_id,
);
return;
}
for sub_id in &agent.sub_agents {
if !project.agents.contains_key(sub_id) {
result.add_error_with_context(
ValidationErrorCode::InvalidSubAgentRef,
format!("Sub-agent '{}' does not exist", sub_id),
agent_id,
);
}
}
if agent.agent_type == AgentType::Loop {
if let Some(max_iter) = agent.max_iterations {
if max_iter == 0 {
result.add_warning_with_context(
ValidationErrorCode::MissingRequiredField,
"Loop agent has max_iterations=0, will not execute",
agent_id,
);
}
}
}
}
fn validate_tool_configs(project: &ProjectSchema, result: &mut ValidationResult) {
for (tool_id, config) in &project.tool_configs {
match config {
ToolConfig::Mcp(mcp) => {
if mcp.server_command.trim().is_empty() {
result.add_error_with_context(
ValidationErrorCode::InvalidToolConfig,
"MCP tool must have a server command",
tool_id,
);
}
}
ToolConfig::Function(func) => {
if func.name.trim().is_empty() {
result.add_error_with_context(
ValidationErrorCode::InvalidToolConfig,
"Function tool must have a name",
tool_id,
);
}
if func.description.trim().is_empty() {
result.add_warning_with_context(
ValidationErrorCode::InvalidToolConfig,
"Function tool has no description",
tool_id,
);
}
}
ToolConfig::Browser(_) => {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{Edge, Position, Route};
fn create_test_project() -> ProjectSchema {
let mut project = ProjectSchema::new("test");
project.agents.insert(
"agent1".to_string(),
AgentSchema {
agent_type: AgentType::Llm,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Test instruction".to_string(),
tools: vec![],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
project.workflow.edges = vec![Edge::new(START, "agent1"), Edge::new("agent1", END)];
project
}
#[test]
fn test_valid_project() {
let project = create_test_project();
let result = validate_project(&project);
assert!(
result.is_valid(),
"Expected valid project, got errors: {:?}",
result.errors
);
}
#[test]
fn test_empty_agents() {
let mut project = create_test_project();
project.agents.clear();
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::NoAgents)
);
}
#[test]
fn test_empty_edges() {
let mut project = create_test_project();
project.workflow.edges.clear();
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::NoEdges)
);
}
#[test]
fn test_missing_start_edge() {
let mut project = create_test_project();
project.workflow.edges = vec![Edge::new("agent1", END)];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::MissingStartEdge)
);
}
#[test]
fn test_missing_end_edge() {
let mut project = create_test_project();
project.workflow.edges = vec![Edge::new(START, "agent1")];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::MissingEndEdge)
);
}
#[test]
fn test_disconnected_node() {
let mut project = create_test_project();
project.agents.insert(
"agent2".to_string(),
AgentSchema {
agent_type: AgentType::Llm,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Disconnected".to_string(),
tools: vec![],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::DisconnectedNode)
);
}
#[test]
fn test_router_without_routes() {
let mut project = create_test_project();
project.agents.insert(
"router".to_string(),
AgentSchema {
agent_type: AgentType::Router,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Route".to_string(),
tools: vec![],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![], },
);
project.workflow.edges = vec![Edge::new(START, "router"), Edge::new("router", END)];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::InvalidRouteConfig)
);
}
#[test]
fn test_router_with_invalid_target() {
let mut project = create_test_project();
project.agents.insert(
"router".to_string(),
AgentSchema {
agent_type: AgentType::Router,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Route".to_string(),
tools: vec![],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![Route {
condition: "test".to_string(),
target: "nonexistent".to_string(), }],
},
);
project.workflow.edges = vec![Edge::new(START, "router"), Edge::new("router", END)];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::InvalidRouteConfig)
);
}
#[test]
fn test_sequential_without_subagents() {
let mut project = create_test_project();
project.agents.insert(
"seq".to_string(),
AgentSchema {
agent_type: AgentType::Sequential,
model: None,
instruction: String::new(),
tools: vec![],
sub_agents: vec![], position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
project.workflow.edges = vec![Edge::new(START, "seq"), Edge::new("seq", END)];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::MissingRequiredField)
);
}
#[test]
fn test_sequential_with_invalid_subagent() {
let mut project = create_test_project();
project.agents.insert(
"seq".to_string(),
AgentSchema {
agent_type: AgentType::Sequential,
model: None,
instruction: String::new(),
tools: vec![],
sub_agents: vec!["nonexistent".to_string()], position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
project.workflow.edges = vec![Edge::new(START, "seq"), Edge::new("seq", END)];
let result = validate_project(&project);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.code == ValidationErrorCode::InvalidSubAgentRef)
);
}
}
pub fn get_required_env_vars(project: &ProjectSchema) -> Vec<EnvVarRequirement> {
let mut env_vars = Vec::new();
let providers = super::collect_providers(project);
if providers.contains("gemini") {
env_vars.push(EnvVarRequirement {
name: "GOOGLE_API_KEY".to_string(),
description: "Google AI API key for Gemini models".to_string(),
alternatives: vec!["GEMINI_API_KEY".to_string()],
required: true,
});
}
if providers.contains("openai") {
env_vars.push(EnvVarRequirement {
name: "OPENAI_API_KEY".to_string(),
description: "OpenAI API key for GPT models".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("anthropic") {
env_vars.push(EnvVarRequirement {
name: "ANTHROPIC_API_KEY".to_string(),
description: "Anthropic API key for Claude models".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("deepseek") {
env_vars.push(EnvVarRequirement {
name: "DEEPSEEK_API_KEY".to_string(),
description: "DeepSeek API key".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("groq") {
env_vars.push(EnvVarRequirement {
name: "GROQ_API_KEY".to_string(),
description: "Groq API key for fast inference".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("ollama") {
env_vars.push(EnvVarRequirement {
name: "OLLAMA_HOST".to_string(),
description: "Ollama server URL (defaults to http://localhost:11434)".to_string(),
alternatives: vec![],
required: false, });
}
if providers.contains("fireworks") {
env_vars.push(EnvVarRequirement {
name: "FIREWORKS_API_KEY".to_string(),
description: "Fireworks AI API key".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("together") {
env_vars.push(EnvVarRequirement {
name: "TOGETHER_API_KEY".to_string(),
description: "Together AI API key".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("mistral") {
env_vars.push(EnvVarRequirement {
name: "MISTRAL_API_KEY".to_string(),
description: "Mistral AI API key".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("perplexity") {
env_vars.push(EnvVarRequirement {
name: "PERPLEXITY_API_KEY".to_string(),
description: "Perplexity API key for Sonar models".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("cerebras") {
env_vars.push(EnvVarRequirement {
name: "CEREBRAS_API_KEY".to_string(),
description: "Cerebras API key for ultra-fast inference".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("sambanova") {
env_vars.push(EnvVarRequirement {
name: "SAMBANOVA_API_KEY".to_string(),
description: "SambaNova API key".to_string(),
alternatives: vec![],
required: true,
});
}
if providers.contains("bedrock") {
env_vars.push(EnvVarRequirement {
name: "AWS_ACCESS_KEY_ID".to_string(),
description: "AWS credentials for Amazon Bedrock (or use IAM roles/SSO)".to_string(),
alternatives: vec!["AWS_PROFILE".to_string()],
required: true,
});
env_vars.push(EnvVarRequirement {
name: "AWS_DEFAULT_REGION".to_string(),
description: "AWS region for Bedrock (defaults to us-east-1)".to_string(),
alternatives: vec!["AWS_REGION".to_string()],
required: false,
});
}
if providers.contains("azure-ai") {
env_vars.push(EnvVarRequirement {
name: "AZURE_AI_ENDPOINT".to_string(),
description: "Azure AI Inference endpoint URL".to_string(),
alternatives: vec![],
required: true,
});
env_vars.push(EnvVarRequirement {
name: "AZURE_AI_API_KEY".to_string(),
description: "Azure AI API key".to_string(),
alternatives: vec![],
required: true,
});
}
for (tool_id, config) in &project.tool_configs {
if let ToolConfig::Mcp(mcp) = config {
if mcp.server_command.contains("github")
|| mcp.server_args.iter().any(|a| a.contains("github"))
{
env_vars.push(EnvVarRequirement {
name: "GITHUB_TOKEN".to_string(),
description: format!("GitHub token for MCP server ({})", tool_id),
alternatives: vec!["GITHUB_PERSONAL_ACCESS_TOKEN".to_string()],
required: false, });
}
if mcp.server_command.contains("slack")
|| mcp.server_args.iter().any(|a| a.contains("slack"))
{
env_vars.push(EnvVarRequirement {
name: "SLACK_BOT_TOKEN".to_string(),
description: format!("Slack bot token for MCP server ({})", tool_id),
alternatives: vec![],
required: true,
});
}
}
}
let uses_browser = project
.agents
.values()
.any(|a| a.tools.contains(&"browser".to_string()));
if uses_browser {
env_vars.push(EnvVarRequirement {
name: "CHROME_PATH".to_string(),
description: "Path to Chrome/Chromium executable (optional, auto-detected if not set)"
.to_string(),
alternatives: vec!["CHROMIUM_PATH".to_string()],
required: false,
});
}
env_vars
}
#[derive(Debug, Clone)]
pub struct EnvVarRequirement {
pub name: String,
pub description: String,
pub alternatives: Vec<String>,
pub required: bool,
}
impl EnvVarRequirement {
pub fn is_set(&self) -> bool {
if std::env::var(&self.name).is_ok() {
return true;
}
self.alternatives
.iter()
.any(|alt| std::env::var(alt).is_ok())
}
pub fn all_names(&self) -> Vec<&str> {
let mut names = vec![self.name.as_str()];
names.extend(self.alternatives.iter().map(|s| s.as_str()));
names
}
}
pub fn check_env_vars(project: &ProjectSchema) -> Vec<EnvVarWarning> {
let requirements = get_required_env_vars(project);
let mut warnings = Vec::new();
for req in requirements {
if !req.is_set() {
warnings.push(EnvVarWarning {
variable: req.name.clone(),
description: req.description.clone(),
alternatives: req.alternatives.clone(),
required: req.required,
});
}
}
warnings
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvVarWarning {
pub variable: String,
pub description: String,
pub alternatives: Vec<String>,
pub required: bool,
}
impl std::fmt::Display for EnvVarWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.required {
write!(f, "Required: {} - {}", self.variable, self.description)?;
} else {
write!(f, "Optional: {} - {}", self.variable, self.description)?;
}
if !self.alternatives.is_empty() {
write!(f, " (alternatives: {})", self.alternatives.join(", "))?;
}
Ok(())
}
}
#[cfg(test)]
mod env_var_tests {
use super::*;
use crate::schema::{McpToolConfig, Position};
#[test]
fn test_gemini_requires_api_key() {
let mut project = ProjectSchema::new("test");
project.agents.insert(
"agent".to_string(),
AgentSchema {
agent_type: AgentType::Llm,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Test".to_string(),
tools: vec![],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
let env_vars = get_required_env_vars(&project);
assert!(env_vars.iter().any(|v| v.name == "GOOGLE_API_KEY"));
}
#[test]
fn test_browser_tool_env_var() {
let mut project = ProjectSchema::new("test");
project.agents.insert(
"agent".to_string(),
AgentSchema {
agent_type: AgentType::Llm,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Test".to_string(),
tools: vec!["browser".to_string()],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
let env_vars = get_required_env_vars(&project);
assert!(env_vars.iter().any(|v| v.name == "CHROME_PATH"));
}
#[test]
fn test_github_mcp_env_var() {
let mut project = ProjectSchema::new("test");
project.agents.insert(
"agent".to_string(),
AgentSchema {
agent_type: AgentType::Llm,
model: Some("gemini-3.1-flash-lite-preview".to_string()),
instruction: "Test".to_string(),
tools: vec!["mcp".to_string()],
sub_agents: vec![],
position: Position::default(),
max_iterations: None,
temperature: None,
top_p: None,
top_k: None,
max_output_tokens: None,
routes: vec![],
},
);
project.tool_configs.insert(
"agent_mcp".to_string(),
ToolConfig::Mcp(McpToolConfig {
server_command: "npx".to_string(),
server_args: vec![
"-y".to_string(),
"@modelcontextprotocol/server-github".to_string(),
],
tool_filter: vec![],
}),
);
let env_vars = get_required_env_vars(&project);
assert!(env_vars.iter().any(|v| v.name == "GITHUB_TOKEN"));
}
}