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 mcp;
10pub mod plugin;
11pub mod runtime;
12pub mod scheduler;
13pub mod settings;
14pub mod skills;
15
16pub use includable::IncludableString;
17
18pub use agent_config::{
19    AGENT_CONFIG_FILENAME, AgentCardConfig, AgentConfig, AgentMetadataConfig, AgentProviderInfo,
20    AgentSkillConfig, AgentSummary, CapabilitiesConfig, DEFAULT_AGENT_SYSTEM_PROMPT_FILE,
21    DiskAgentConfig, OAuthConfig,
22};
23pub use ai::{
24    AiConfig, AiProviderConfig, HistoryConfig, McpConfig, ModelCapabilities, ModelDefinition,
25    ModelLimits, ModelPricing, SamplingConfig, ToolModelConfig, ToolModelSettings,
26};
27pub use content::ContentConfig;
28pub use external_agent::{ExternalAgentConfig, ExternalAgentKind};
29pub use hooks::{
30    DiskHookConfig, HOOK_CONFIG_FILENAME, HookAction, HookCategory, HookEvent, HookEventsConfig,
31    HookMatcher, HookType,
32};
33pub use mcp::McpServerSummary;
34pub use plugin::{
35    ComponentFilter, ComponentSource, PluginAuthor, PluginComponentRef, PluginConfig,
36    PluginConfigFile, PluginScript, PluginSummary, PluginVariableDef,
37};
38pub use runtime::{RuntimeStatus, ServiceType};
39pub use scheduler::*;
40pub use settings::*;
41pub use skills::{
42    DEFAULT_SKILL_CONTENT_FILE, DiskSkillConfig, SKILL_CONFIG_FILENAME, SkillConfig, SkillDetail,
43    SkillSummary, SkillsConfig, strip_frontmatter,
44};
45pub use systemprompt_provider_contracts::{BrandingConfig, WebConfig};
46
47use crate::errors::ConfigValidationError;
48use crate::mcp::{Deployment, McpServerType};
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use systemprompt_identifiers::ExternalAgentId;
52
53/// The single canonical shape of a services config file.
54///
55/// A root config file and an include file deserialize into the same struct.
56/// `settings` is meaningful only at the root; the loader rejects an include
57/// that sets it (`ConfigLoadError::IncludeMustNotSetGlobalSettings`) rather
58/// than silently ignoring the value.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct ServicesConfig {
62    #[serde(default)]
63    pub includes: Vec<String>,
64    #[serde(default)]
65    pub settings: Settings,
66    #[serde(default)]
67    pub agents: HashMap<String, AgentConfig>,
68    #[serde(default)]
69    pub mcp_servers: HashMap<String, Deployment>,
70    #[serde(default)]
71    pub scheduler: Option<SchedulerConfig>,
72    #[serde(default)]
73    pub ai: AiConfig,
74    #[serde(default)]
75    pub web: Option<WebConfig>,
76    #[serde(default)]
77    pub plugins: HashMap<String, PluginConfig>,
78    #[serde(default)]
79    pub skills: SkillsConfig,
80    #[serde(default)]
81    pub content: ContentConfig,
82    #[serde(default)]
83    pub external_agents: HashMap<ExternalAgentId, ExternalAgentConfig>,
84}
85
86impl ServicesConfig {
87    pub fn validate(&self) -> Result<(), ConfigValidationError> {
88        self.validate_port_conflicts()?;
89        self.validate_port_ranges()?;
90        self.validate_mcp_port_ranges()?;
91        self.validate_single_default_agent()?;
92
93        for (name, agent) in &self.agents {
94            agent.validate(name)?;
95        }
96
97        for (name, plugin) in &self.plugins {
98            plugin.validate(name)?;
99            self.validate_plugin_bindings(name, plugin)?;
100        }
101
102        Ok(())
103    }
104
105    fn validate_plugin_bindings(
106        &self,
107        plugin_name: &str,
108        plugin: &PluginConfig,
109    ) -> Result<(), ConfigValidationError> {
110        for mcp_ref in &plugin.mcp_servers {
111            if !self.mcp_servers.contains_key(mcp_ref) {
112                return Err(ConfigValidationError::unknown_reference(format!(
113                    "Plugin '{plugin_name}': mcp_servers references unknown mcp_server '{mcp_ref}'"
114                )));
115            }
116        }
117
118        for agent_ref in &plugin.agents.include {
119            if !self.agents.contains_key(agent_ref) {
120                return Err(ConfigValidationError::unknown_reference(format!(
121                    "Plugin '{plugin_name}': agents.include references unknown agent '{agent_ref}'"
122                )));
123            }
124        }
125
126        self.validate_skills()?;
127
128        Ok(())
129    }
130
131    fn validate_skills(&self) -> Result<(), ConfigValidationError> {
132        for (key, skill) in &self.skills.skills {
133            if !skill.id.as_str().is_empty() && skill.id.as_str() != key.as_str() {
134                return Err(ConfigValidationError::invalid_field(format!(
135                    "Skill map key '{}' does not match skill id '{}'",
136                    key, skill.id
137                )));
138            }
139
140            for agent_ref in &skill.assigned_agents {
141                if !self.agents.contains_key(agent_ref) {
142                    tracing::warn!(
143                        skill = %key,
144                        agent = %agent_ref,
145                        "Skill references agent that is not defined in services config"
146                    );
147                }
148            }
149
150            for mcp_ref in &skill.mcp_servers {
151                if !self.mcp_servers.contains_key(mcp_ref) {
152                    tracing::warn!(
153                        skill = %key,
154                        mcp_server = %mcp_ref,
155                        "Skill references MCP server that is not defined in services config"
156                    );
157                }
158            }
159        }
160
161        Ok(())
162    }
163
164    fn validate_port_conflicts(&self) -> Result<(), ConfigValidationError> {
165        let mut seen_ports = HashMap::new();
166
167        for (name, agent) in &self.agents {
168            if let Some(existing) = seen_ports.insert(agent.port, ("agent", name.as_str())) {
169                return Err(ConfigValidationError::port_conflict(format!(
170                    "Port conflict: {} used by both {} '{}' and agent '{}'",
171                    agent.port, existing.0, existing.1, name
172                )));
173            }
174        }
175
176        for (name, mcp) in &self.mcp_servers {
177            if mcp.server_type == McpServerType::External {
178                continue;
179            }
180            if let Some(existing) = seen_ports.insert(mcp.port, ("mcp_server", name.as_str())) {
181                return Err(ConfigValidationError::port_conflict(format!(
182                    "Port conflict: {} used by both {} '{}' and mcp_server '{}'",
183                    mcp.port, existing.0, existing.1, name
184                )));
185            }
186        }
187
188        Ok(())
189    }
190
191    fn validate_port_ranges(&self) -> Result<(), ConfigValidationError> {
192        let (min, max) = self.settings.agent_port_range;
193
194        for (name, agent) in &self.agents {
195            if agent.port < min || agent.port > max {
196                return Err(ConfigValidationError::invalid_field(format!(
197                    "Agent '{}' port {} is outside allowed range {}-{}",
198                    name, agent.port, min, max
199                )));
200            }
201        }
202
203        Ok(())
204    }
205
206    fn validate_mcp_port_ranges(&self) -> Result<(), ConfigValidationError> {
207        let (min, max) = self.settings.mcp_port_range;
208
209        for (name, mcp) in &self.mcp_servers {
210            if mcp.server_type == McpServerType::External {
211                continue;
212            }
213            if mcp.port < min || mcp.port > max {
214                return Err(ConfigValidationError::invalid_field(format!(
215                    "MCP server '{}' port {} is outside allowed range {}-{}",
216                    name, mcp.port, min, max
217                )));
218            }
219        }
220
221        Ok(())
222    }
223
224    fn validate_single_default_agent(&self) -> Result<(), ConfigValidationError> {
225        let default_agents: Vec<&str> = self
226            .agents
227            .iter()
228            .filter_map(|(name, agent)| {
229                if agent.default {
230                    Some(name.as_str())
231                } else {
232                    None
233                }
234            })
235            .collect();
236
237        match default_agents.len() {
238            0 | 1 => Ok(()),
239            _ => Err(ConfigValidationError::business_rule(format!(
240                "Multiple agents marked as default: {}. Only one agent can have 'default: true'",
241                default_agents.join(", ")
242            ))),
243        }
244    }
245}