Skip to main content

systemprompt_models/validators/
agents.rs

1use super::ValidationConfigProvider;
2use crate::ServicesConfig;
3use std::collections::HashMap;
4use std::path::Path;
5use systemprompt_traits::validation_report::{ValidationError, ValidationReport};
6use systemprompt_traits::{ConfigProvider, DomainConfig, DomainConfigError};
7
8#[derive(Debug, Default)]
9pub struct AgentConfigValidator {
10    config: Option<ServicesConfig>,
11    skills_path: Option<String>,
12}
13
14impl AgentConfigValidator {
15    pub fn new() -> Self {
16        Self::default()
17    }
18}
19
20impl DomainConfig for AgentConfigValidator {
21    fn domain_id(&self) -> &'static str {
22        "agents"
23    }
24
25    fn priority(&self) -> u32 {
26        30
27    }
28
29    fn load(&mut self, config: &dyn ConfigProvider) -> Result<(), DomainConfigError> {
30        let skills_path = config
31            .get("skills_path")
32            .ok_or_else(|| DomainConfigError::NotFound("skills_path not configured".into()))?;
33
34        self.skills_path = Some(skills_path);
35
36        let provider = config
37            .as_any()
38            .downcast_ref::<ValidationConfigProvider>()
39            .ok_or_else(|| DomainConfigError::LoadError {
40                message: "Expected ValidationConfigProvider with merged ServicesConfig".into(),
41            })?;
42
43        self.config = Some(provider.services_config().clone());
44        Ok(())
45    }
46
47    fn validate(&self) -> Result<ValidationReport, DomainConfigError> {
48        let mut report = ValidationReport::new("agents");
49
50        let config = self
51            .config
52            .as_ref()
53            .ok_or_else(|| DomainConfigError::ValidationError {
54                message: "Not loaded".into(),
55            })?;
56
57        let skills_path =
58            self.skills_path
59                .as_ref()
60                .ok_or_else(|| DomainConfigError::ValidationError {
61                    message: "Skills path not set".into(),
62                })?;
63
64        if !Path::new(skills_path).exists() {
65            report.add_error(
66                ValidationError::new("skills_path", "Skills directory does not exist")
67                    .with_path(skills_path)
68                    .with_suggestion("Create the skills directory"),
69            );
70        }
71
72        Self::validate_port_uniqueness(config, &mut report);
73        for (name, agent) in &config.agents {
74            Self::validate_agent(name, agent, config, skills_path, &mut report);
75        }
76
77        Ok(report)
78    }
79}
80
81impl AgentConfigValidator {
82    fn validate_port_uniqueness(config: &ServicesConfig, report: &mut ValidationReport) {
83        let mut used_ports: HashMap<u16, String> = HashMap::new();
84        for (name, agent) in &config.agents {
85            if let Some(existing) = used_ports.get(&agent.port) {
86                report.add_error(
87                    ValidationError::new(
88                        format!("agents.{}.port", name),
89                        format!("Port {} already used by agent '{}'", agent.port, existing),
90                    )
91                    .with_suggestion("Assign unique ports to each agent"),
92                );
93            } else {
94                used_ports.insert(agent.port, name.clone());
95            }
96        }
97    }
98
99    fn validate_agent(
100        name: &str,
101        agent: &crate::services::AgentConfig,
102        config: &ServicesConfig,
103        skills_path: &str,
104        report: &mut ValidationReport,
105    ) {
106        if agent.name.is_empty() {
107            report.add_error(ValidationError::new(
108                format!("agents.{}.name", name),
109                "Agent name cannot be empty",
110            ));
111        }
112
113        // NOTE: `agent.card.skills` is deprecated and no longer the source
114        // of truth — the A2A endpoint and the bridge marketplace derive
115        // the card's skills list from `agent.metadata.skills` joined
116        // against the on-disk `services/skills/` catalog. Only
117        // `metadata.skills` is validated here.
118
119        for skill_id in &agent.metadata.skills.include {
120            let skill_path = Path::new(skills_path).join(skill_id);
121            if !skill_path.exists() {
122                report.add_error(
123                    ValidationError::new(
124                        format!("agents.{}.metadata.skills", name),
125                        format!("Skill '{}' directory not found", skill_id),
126                    )
127                    .with_path(&skill_path)
128                    .with_suggestion("Create the skill directory or remove it from the agent"),
129                );
130            }
131        }
132
133        for mcp_server in &agent.metadata.mcp_servers.include {
134            Self::validate_agent_mcp_ref(name, agent, mcp_server, config, report);
135        }
136    }
137
138    fn validate_agent_mcp_ref(
139        name: &str,
140        agent: &crate::services::AgentConfig,
141        mcp_server: &str,
142        config: &ServicesConfig,
143        report: &mut ValidationReport,
144    ) {
145        let Some(mcp_config) = config.mcp_servers.get(mcp_server) else {
146            report.add_error(
147                ValidationError::new(
148                    format!("agents.{}.metadata.mcp_servers", name),
149                    format!("MCP server '{}' is not defined", mcp_server),
150                )
151                .with_suggestion(
152                    "Define the MCP server in mcp_servers or remove it from the agent",
153                ),
154            );
155            return;
156        };
157
158        if mcp_config.dev_only && !agent.dev_only {
159            report.add_error(
160                ValidationError::new(
161                    format!("agents.{}.metadata.mcp_servers", name),
162                    format!(
163                        "Production agent '{}' references dev-only MCP server '{}'",
164                        name, mcp_server
165                    ),
166                )
167                .with_suggestion(
168                    "Either mark the agent as dev_only: true, or use a production MCP server",
169                ),
170            );
171        }
172    }
173}