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