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