Skip to main content

systemprompt_models/services/
mod.rs

1//! `services` module — see crate-level docs for context.
2
3pub mod agent_config;
4pub mod ai;
5pub mod external_agent;
6pub mod hooks;
7mod includable;
8pub mod marketplace;
9pub mod mcp;
10pub mod plugin;
11pub mod runtime;
12pub mod scheduler;
13pub mod settings;
14pub mod skills;
15pub mod system_admin;
16
17pub use includable::IncludableString;
18
19pub use agent_config::{
20    AGENT_CONFIG_FILENAME, AgentCardConfig, AgentConfig, AgentMetadataConfig, AgentProviderInfo,
21    AgentSkillConfig, AgentSummary, CapabilitiesConfig, DEFAULT_AGENT_SYSTEM_PROMPT_FILE,
22    DiskAgentConfig, OAuthConfig,
23};
24pub use ai::{
25    AiConfig, AiProviderConfig, HistoryConfig, McpConfig, ModelCapabilities, ModelDefinition,
26    ModelLimits, ModelPricing, ResilienceSettings, SamplingConfig, ToolModelConfig,
27    ToolModelSettings,
28};
29pub use external_agent::{ExternalAgentConfig, ExternalAgentKind};
30pub use hooks::{
31    DiskHookConfig, HOOK_CONFIG_FILENAME, HookAction, HookCategory, HookEvent, HookEventsConfig,
32    HookMatcher, HookType,
33};
34pub use marketplace::{
35    MarketplaceAccess, MarketplaceConfig, MarketplaceConfigFile, MarketplaceVisibility,
36};
37pub use mcp::McpServerSummary;
38pub use plugin::{
39    ComponentFilter, ComponentSource, PluginAuthor, PluginComponentRef, PluginConfig,
40    PluginConfigFile, PluginScript, PluginSummary, PluginVariableDef,
41};
42pub use runtime::{RuntimeStatus, ServiceType};
43pub use scheduler::*;
44pub use settings::*;
45pub use skills::{
46    DEFAULT_SKILL_CONTENT_FILE, DiskSkillConfig, SKILL_CONFIG_FILENAME, SkillConfig, SkillDetail,
47    SkillSummary, SkillsConfig, strip_frontmatter,
48};
49pub use system_admin::{SystemAdmin, SystemAdminConfig};
50pub use systemprompt_provider_contracts::{BrandingConfig, WebConfig};
51
52use crate::errors::ConfigValidationError;
53use crate::mcp::{Deployment, McpServerType};
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use systemprompt_identifiers::{ExternalAgentId, MarketplaceId};
57
58/// The single canonical shape of a services config file.
59///
60/// A root config file and an include file deserialize into the same struct.
61/// `settings` is meaningful only at the root; the loader rejects an include
62/// that sets it (`ConfigLoadError::IncludeMustNotSetGlobalSettings`) rather
63/// than silently ignoring the value.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ServicesConfig {
67    #[serde(default)]
68    pub includes: Vec<String>,
69    #[serde(default)]
70    pub settings: Settings,
71    #[serde(default)]
72    pub agents: HashMap<String, AgentConfig>,
73    #[serde(default)]
74    pub mcp_servers: HashMap<String, Deployment>,
75    #[serde(default)]
76    pub scheduler: Option<SchedulerConfig>,
77    #[serde(default)]
78    pub ai: AiConfig,
79    #[serde(default)]
80    pub web: Option<WebConfig>,
81    #[serde(default)]
82    pub plugins: HashMap<String, PluginConfig>,
83    #[serde(default)]
84    pub marketplaces: HashMap<MarketplaceId, MarketplaceConfig>,
85    #[serde(default)]
86    pub skills: SkillsConfig,
87    #[serde(default)]
88    pub external_agents: HashMap<ExternalAgentId, ExternalAgentConfig>,
89}
90
91impl ServicesConfig {
92    pub fn validate(&self) -> Result<(), ConfigValidationError> {
93        self.validate_port_conflicts()?;
94        self.validate_port_ranges()?;
95        self.validate_mcp_port_ranges()?;
96        self.validate_single_default_agent()?;
97
98        for (name, agent) in &self.agents {
99            agent.validate(name)?;
100        }
101
102        for (name, mcp) in &self.mcp_servers {
103            mcp.validate(name)?;
104        }
105
106        for (name, plugin) in &self.plugins {
107            plugin.validate(name)?;
108            self.validate_plugin_bindings(name, plugin)?;
109        }
110
111        for (id, marketplace) in &self.marketplaces {
112            marketplace.validate(id.as_str())?;
113            self.validate_marketplace_bindings(id.as_str(), marketplace)?;
114        }
115
116        self.validate_default_marketplace_selector()?;
117
118        Ok(())
119    }
120
121    fn validate_default_marketplace_selector(&self) -> Result<(), ConfigValidationError> {
122        if self.marketplaces.len() > 1 && self.settings.default_marketplace_id.is_none() {
123            return Err(ConfigValidationError::business_rule(format!(
124                "{} marketplaces are configured but settings.default_marketplace_id is unset; set \
125                 it to select the active marketplace",
126                self.marketplaces.len()
127            )));
128        }
129
130        if let Some(id) = &self.settings.default_marketplace_id {
131            if !self.marketplaces.keys().any(|k| k.as_str() == id.as_str()) {
132                return Err(ConfigValidationError::unknown_reference(format!(
133                    "settings.default_marketplace_id '{}' does not match any configured \
134                     marketplace",
135                    id.as_str()
136                )));
137            }
138        }
139
140        Ok(())
141    }
142
143    fn validate_marketplace_bindings(
144        &self,
145        name: &str,
146        marketplace: &MarketplaceConfig,
147    ) -> Result<(), ConfigValidationError> {
148        for plugin_ref in &marketplace.plugins.include {
149            if !self.plugins.contains_key(plugin_ref) {
150                return Err(ConfigValidationError::unknown_reference(format!(
151                    "Marketplace '{name}': plugins.include references unknown plugin \
152                     '{plugin_ref}'"
153                )));
154            }
155        }
156
157        for skill_ref in &marketplace.skills.include {
158            let exists = self.skills.skills.keys().any(|k| k.as_str() == skill_ref);
159            if !exists {
160                return Err(ConfigValidationError::unknown_reference(format!(
161                    "Marketplace '{name}': skills.include references unknown skill '{skill_ref}'"
162                )));
163            }
164        }
165
166        for mcp_ref in &marketplace.mcp_servers.include {
167            if !self.mcp_servers.contains_key(mcp_ref) {
168                return Err(ConfigValidationError::unknown_reference(format!(
169                    "Marketplace '{name}': mcp_servers.include references unknown mcp_server \
170                     '{mcp_ref}'"
171                )));
172            }
173        }
174
175        for agent_ref in &marketplace.agents.include {
176            if !self.agents.contains_key(agent_ref) {
177                return Err(ConfigValidationError::unknown_reference(format!(
178                    "Marketplace '{name}': agents.include references unknown agent '{agent_ref}'"
179                )));
180            }
181        }
182
183        Ok(())
184    }
185
186    fn validate_plugin_bindings(
187        &self,
188        plugin_name: &str,
189        plugin: &PluginConfig,
190    ) -> Result<(), ConfigValidationError> {
191        for mcp_ref in &plugin.mcp_servers.include {
192            if !self.mcp_servers.contains_key(mcp_ref) {
193                return Err(ConfigValidationError::unknown_reference(format!(
194                    "Plugin '{plugin_name}': mcp_servers.include references unknown mcp_server \
195                     '{mcp_ref}'"
196                )));
197            }
198        }
199
200        for agent_ref in &plugin.agents.include {
201            if !self.agents.contains_key(agent_ref) {
202                return Err(ConfigValidationError::unknown_reference(format!(
203                    "Plugin '{plugin_name}': agents.include references unknown agent '{agent_ref}'"
204                )));
205            }
206        }
207
208        self.validate_skills()?;
209
210        Ok(())
211    }
212
213    fn validate_skills(&self) -> Result<(), ConfigValidationError> {
214        for (key, skill) in &self.skills.skills {
215            if !skill.id.as_str().is_empty() && skill.id.as_str() != key.as_str() {
216                return Err(ConfigValidationError::invalid_field(format!(
217                    "Skill map key '{}' does not match skill id '{}'",
218                    key, skill.id
219                )));
220            }
221
222            for agent_ref in &skill.assigned_agents.include {
223                if !self.agents.contains_key(agent_ref) {
224                    tracing::warn!(
225                        skill = %key,
226                        agent = %agent_ref,
227                        "Skill references agent that is not defined in services config"
228                    );
229                }
230            }
231
232            for mcp_ref in &skill.mcp_servers.include {
233                if !self.mcp_servers.contains_key(mcp_ref) {
234                    tracing::warn!(
235                        skill = %key,
236                        mcp_server = %mcp_ref,
237                        "Skill references MCP server that is not defined in services config"
238                    );
239                }
240            }
241        }
242
243        Ok(())
244    }
245
246    fn validate_port_conflicts(&self) -> Result<(), ConfigValidationError> {
247        let mut seen_ports = HashMap::new();
248
249        for (name, agent) in &self.agents {
250            if let Some(existing) = seen_ports.insert(agent.port, ("agent", name.as_str())) {
251                return Err(ConfigValidationError::port_conflict(format!(
252                    "Port conflict: {} used by both {} '{}' and agent '{}'",
253                    agent.port, existing.0, existing.1, name
254                )));
255            }
256        }
257
258        for (name, mcp) in &self.mcp_servers {
259            if mcp.server_type == McpServerType::External {
260                continue;
261            }
262            if let Some(existing) = seen_ports.insert(mcp.port, ("mcp_server", name.as_str())) {
263                return Err(ConfigValidationError::port_conflict(format!(
264                    "Port conflict: {} used by both {} '{}' and mcp_server '{}'",
265                    mcp.port, existing.0, existing.1, name
266                )));
267            }
268        }
269
270        Ok(())
271    }
272
273    fn validate_port_ranges(&self) -> Result<(), ConfigValidationError> {
274        let (min, max) = self.settings.agent_port_range;
275
276        for (name, agent) in &self.agents {
277            if agent.port < min || agent.port > max {
278                return Err(ConfigValidationError::invalid_field(format!(
279                    "Agent '{}' port {} is outside allowed range {}-{}",
280                    name, agent.port, min, max
281                )));
282            }
283        }
284
285        Ok(())
286    }
287
288    fn validate_mcp_port_ranges(&self) -> Result<(), ConfigValidationError> {
289        let (min, max) = self.settings.mcp_port_range;
290
291        for (name, mcp) in &self.mcp_servers {
292            if mcp.server_type == McpServerType::External {
293                continue;
294            }
295            if mcp.port < min || mcp.port > max {
296                return Err(ConfigValidationError::invalid_field(format!(
297                    "MCP server '{}' port {} is outside allowed range {}-{}",
298                    name, mcp.port, min, max
299                )));
300            }
301        }
302
303        Ok(())
304    }
305
306    fn validate_single_default_agent(&self) -> Result<(), ConfigValidationError> {
307        let default_agents: Vec<&str> = self
308            .agents
309            .iter()
310            .filter_map(|(name, agent)| {
311                if agent.default {
312                    Some(name.as_str())
313                } else {
314                    None
315                }
316            })
317            .collect();
318
319        match default_agents.len() {
320            0 | 1 => Ok(()),
321            _ => Err(ConfigValidationError::business_rule(format!(
322                "Multiple agents marked as default: {}. Only one agent can have 'default: true'",
323                default_agents.join(", ")
324            ))),
325        }
326    }
327}