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