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