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