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;
16pub mod system_admin;
17
18pub use includable::IncludableString;
19
20pub use agent_config::{
21    AGENT_CONFIG_FILENAME, AgentCardConfig, AgentConfig, AgentMetadataConfig, AgentProviderInfo,
22    AgentSkillConfig, AgentSummary, CapabilitiesConfig, DEFAULT_AGENT_SYSTEM_PROMPT_FILE,
23    DiskAgentConfig, OAuthConfig,
24};
25pub use ai::{
26    AiConfig, AiProviderConfig, HistoryConfig, McpConfig, ModelCapabilities, ModelDefinition,
27    ModelLimits, ModelPricing, ResilienceSettings, SamplingConfig, ToolModelConfig,
28    ToolModelSettings,
29};
30pub use content::ContentConfig;
31pub use external_agent::{ExternalAgentConfig, ExternalAgentKind};
32pub use hooks::{
33    DiskHookConfig, HOOK_CONFIG_FILENAME, HookAction, HookCategory, HookEvent, HookEventsConfig,
34    HookMatcher, HookType,
35};
36pub use marketplace::{MarketplaceConfig, MarketplaceConfigFile, MarketplaceVisibility};
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 content: ContentConfig,
89    #[serde(default)]
90    pub external_agents: HashMap<ExternalAgentId, ExternalAgentConfig>,
91}
92
93impl ServicesConfig {
94    pub fn validate(&self) -> Result<(), ConfigValidationError> {
95        self.validate_port_conflicts()?;
96        self.validate_port_ranges()?;
97        self.validate_mcp_port_ranges()?;
98        self.validate_single_default_agent()?;
99
100        for (name, agent) in &self.agents {
101            agent.validate(name)?;
102        }
103
104        for (name, plugin) in &self.plugins {
105            plugin.validate(name)?;
106            self.validate_plugin_bindings(name, plugin)?;
107        }
108
109        for (id, marketplace) in &self.marketplaces {
110            marketplace.validate(id.as_str())?;
111            self.validate_marketplace_bindings(id.as_str(), marketplace)?;
112        }
113
114        Ok(())
115    }
116
117    fn validate_marketplace_bindings(
118        &self,
119        name: &str,
120        marketplace: &MarketplaceConfig,
121    ) -> Result<(), ConfigValidationError> {
122        for plugin_ref in &marketplace.plugins.include {
123            if !self.plugins.contains_key(plugin_ref) {
124                return Err(ConfigValidationError::unknown_reference(format!(
125                    "Marketplace '{name}': plugins.include references unknown plugin \
126                     '{plugin_ref}'"
127                )));
128            }
129        }
130
131        for skill_ref in &marketplace.skills.include {
132            let exists = self.skills.skills.keys().any(|k| k.as_str() == skill_ref);
133            if !exists {
134                return Err(ConfigValidationError::unknown_reference(format!(
135                    "Marketplace '{name}': skills.include references unknown skill '{skill_ref}'"
136                )));
137            }
138        }
139
140        for mcp_ref in &marketplace.mcp_servers {
141            if !self.mcp_servers.contains_key(mcp_ref) {
142                return Err(ConfigValidationError::unknown_reference(format!(
143                    "Marketplace '{name}': mcp_servers references unknown mcp_server '{mcp_ref}'"
144                )));
145            }
146        }
147
148        for agent_ref in &marketplace.agents.include {
149            if !self.agents.contains_key(agent_ref) {
150                return Err(ConfigValidationError::unknown_reference(format!(
151                    "Marketplace '{name}': agents.include references unknown agent '{agent_ref}'"
152                )));
153            }
154        }
155
156        Ok(())
157    }
158
159    fn validate_plugin_bindings(
160        &self,
161        plugin_name: &str,
162        plugin: &PluginConfig,
163    ) -> Result<(), ConfigValidationError> {
164        for mcp_ref in &plugin.mcp_servers {
165            if !self.mcp_servers.contains_key(mcp_ref) {
166                return Err(ConfigValidationError::unknown_reference(format!(
167                    "Plugin '{plugin_name}': mcp_servers references unknown mcp_server '{mcp_ref}'"
168                )));
169            }
170        }
171
172        for agent_ref in &plugin.agents.include {
173            if !self.agents.contains_key(agent_ref) {
174                return Err(ConfigValidationError::unknown_reference(format!(
175                    "Plugin '{plugin_name}': agents.include references unknown agent '{agent_ref}'"
176                )));
177            }
178        }
179
180        self.validate_skills()?;
181
182        Ok(())
183    }
184
185    fn validate_skills(&self) -> Result<(), ConfigValidationError> {
186        for (key, skill) in &self.skills.skills {
187            if !skill.id.as_str().is_empty() && skill.id.as_str() != key.as_str() {
188                return Err(ConfigValidationError::invalid_field(format!(
189                    "Skill map key '{}' does not match skill id '{}'",
190                    key, skill.id
191                )));
192            }
193
194            for agent_ref in &skill.assigned_agents {
195                if !self.agents.contains_key(agent_ref) {
196                    tracing::warn!(
197                        skill = %key,
198                        agent = %agent_ref,
199                        "Skill references agent that is not defined in services config"
200                    );
201                }
202            }
203
204            for mcp_ref in &skill.mcp_servers {
205                if !self.mcp_servers.contains_key(mcp_ref) {
206                    tracing::warn!(
207                        skill = %key,
208                        mcp_server = %mcp_ref,
209                        "Skill references MCP server that is not defined in services config"
210                    );
211                }
212            }
213        }
214
215        Ok(())
216    }
217
218    fn validate_port_conflicts(&self) -> Result<(), ConfigValidationError> {
219        let mut seen_ports = HashMap::new();
220
221        for (name, agent) in &self.agents {
222            if let Some(existing) = seen_ports.insert(agent.port, ("agent", name.as_str())) {
223                return Err(ConfigValidationError::port_conflict(format!(
224                    "Port conflict: {} used by both {} '{}' and agent '{}'",
225                    agent.port, existing.0, existing.1, name
226                )));
227            }
228        }
229
230        for (name, mcp) in &self.mcp_servers {
231            if mcp.server_type == McpServerType::External {
232                continue;
233            }
234            if let Some(existing) = seen_ports.insert(mcp.port, ("mcp_server", name.as_str())) {
235                return Err(ConfigValidationError::port_conflict(format!(
236                    "Port conflict: {} used by both {} '{}' and mcp_server '{}'",
237                    mcp.port, existing.0, existing.1, name
238                )));
239            }
240        }
241
242        Ok(())
243    }
244
245    fn validate_port_ranges(&self) -> Result<(), ConfigValidationError> {
246        let (min, max) = self.settings.agent_port_range;
247
248        for (name, agent) in &self.agents {
249            if agent.port < min || agent.port > max {
250                return Err(ConfigValidationError::invalid_field(format!(
251                    "Agent '{}' port {} is outside allowed range {}-{}",
252                    name, agent.port, min, max
253                )));
254            }
255        }
256
257        Ok(())
258    }
259
260    fn validate_mcp_port_ranges(&self) -> Result<(), ConfigValidationError> {
261        let (min, max) = self.settings.mcp_port_range;
262
263        for (name, mcp) in &self.mcp_servers {
264            if mcp.server_type == McpServerType::External {
265                continue;
266            }
267            if mcp.port < min || mcp.port > max {
268                return Err(ConfigValidationError::invalid_field(format!(
269                    "MCP server '{}' port {} is outside allowed range {}-{}",
270                    name, mcp.port, min, max
271                )));
272            }
273        }
274
275        Ok(())
276    }
277
278    fn validate_single_default_agent(&self) -> Result<(), ConfigValidationError> {
279        let default_agents: Vec<&str> = self
280            .agents
281            .iter()
282            .filter_map(|(name, agent)| {
283                if agent.default {
284                    Some(name.as_str())
285                } else {
286                    None
287                }
288            })
289            .collect();
290
291        match default_agents.len() {
292            0 | 1 => Ok(()),
293            _ => Err(ConfigValidationError::business_rule(format!(
294                "Multiple agents marked as default: {}. Only one agent can have 'default: true'",
295                default_agents.join(", ")
296            ))),
297        }
298    }
299}