Skip to main content

systemprompt_models/validators/
agents.rs

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