systemprompt_models/services/
mod.rs1pub mod agent_config;
2pub mod ai;
3pub mod content;
4pub mod hooks;
5pub mod mcp;
6pub mod plugin;
7pub mod runtime;
8pub mod scheduler;
9pub mod settings;
10pub mod skills;
11
12pub use agent_config::{
13 AGENT_CONFIG_FILENAME, AgentCardConfig, AgentConfig, AgentMetadataConfig, AgentProviderInfo,
14 AgentSkillConfig, AgentSummary, CapabilitiesConfig, DEFAULT_AGENT_SYSTEM_PROMPT_FILE,
15 DiskAgentConfig, OAuthConfig,
16};
17pub use ai::{
18 AiConfig, AiProviderConfig, HistoryConfig, McpConfig, ModelCapabilities, ModelDefinition,
19 ModelLimits, ModelPricing, SamplingConfig, ToolModelConfig, ToolModelSettings,
20};
21pub use content::ContentConfig;
22pub use hooks::{
23 DiskHookConfig, HOOK_CONFIG_FILENAME, HookAction, HookCategory, HookEvent, HookEventsConfig,
24 HookMatcher, HookType,
25};
26pub use mcp::McpServerSummary;
27pub use plugin::{
28 ComponentFilter, ComponentSource, PluginAuthor, PluginComponentRef, PluginConfig,
29 PluginConfigFile, PluginScript, PluginSummary, PluginVariableDef,
30};
31pub use runtime::{RuntimeStatus, ServiceType};
32pub use scheduler::*;
33pub use settings::*;
34pub use skills::{
35 DEFAULT_SKILL_CONTENT_FILE, DiskSkillConfig, SKILL_CONFIG_FILENAME, SkillConfig, SkillDetail,
36 SkillSummary, SkillsConfig, strip_frontmatter,
37};
38pub use systemprompt_provider_contracts::{BrandingConfig, WebConfig};
39
40use crate::mcp::{Deployment, McpServerType};
41use serde::{Deserialize, Deserializer, Serialize};
42use std::collections::HashMap;
43
44#[derive(Debug, Clone, Serialize)]
45#[serde(untagged)]
46pub enum IncludableString {
47 Inline(String),
48 Include { path: String },
49}
50
51impl<'de> Deserialize<'de> for IncludableString {
52 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 let s = String::deserialize(deserializer)?;
57 s.strip_prefix("!include ")
58 .map_or_else(
59 || Self::Inline(s.clone()),
60 |path| Self::Include {
61 path: path.trim().to_string(),
62 },
63 )
64 .pipe(Ok)
65 }
66}
67
68trait Pipe: Sized {
69 fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
70 f(self)
71 }
72}
73impl<T> Pipe for T {}
74
75impl IncludableString {
76 pub const fn is_include(&self) -> bool {
77 matches!(self, Self::Include { .. })
78 }
79
80 pub fn as_inline(&self) -> Option<&str> {
81 match self {
82 Self::Inline(s) => Some(s),
83 Self::Include { .. } => None,
84 }
85 }
86}
87
88impl Default for IncludableString {
89 fn default() -> Self {
90 Self::Inline(String::new())
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct PartialServicesConfig {
97 #[serde(default)]
98 pub agents: HashMap<String, AgentConfig>,
99 #[serde(default)]
100 pub mcp_servers: HashMap<String, Deployment>,
101 #[serde(default)]
102 pub scheduler: Option<SchedulerConfig>,
103 #[serde(default)]
104 pub ai: Option<AiConfig>,
105 #[serde(default)]
106 pub web: Option<WebConfig>,
107 #[serde(default)]
108 pub plugins: HashMap<String, PluginConfig>,
109 #[serde(default)]
110 pub skills: SkillsConfig,
111 #[serde(default)]
112 pub content: ContentConfig,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct ServicesConfig {
118 #[serde(default)]
119 pub agents: HashMap<String, AgentConfig>,
120 #[serde(default)]
121 pub mcp_servers: HashMap<String, Deployment>,
122 #[serde(default)]
123 pub settings: Settings,
124 #[serde(default)]
125 pub scheduler: Option<SchedulerConfig>,
126 #[serde(default)]
127 pub ai: AiConfig,
128 #[serde(default)]
129 pub web: Option<WebConfig>,
130 #[serde(default)]
131 pub plugins: HashMap<String, PluginConfig>,
132 #[serde(default)]
133 pub skills: SkillsConfig,
134 #[serde(default)]
135 pub content: ContentConfig,
136}
137
138impl ServicesConfig {
139 pub fn validate(&self) -> anyhow::Result<()> {
140 self.validate_port_conflicts()?;
141 self.validate_port_ranges()?;
142 self.validate_mcp_port_ranges()?;
143 self.validate_single_default_agent()?;
144
145 for (name, agent) in &self.agents {
146 agent.validate(name)?;
147 }
148
149 for (name, plugin) in &self.plugins {
150 plugin.validate(name)?;
151 self.validate_plugin_bindings(name, plugin)?;
152 }
153
154 Ok(())
155 }
156
157 fn validate_plugin_bindings(
158 &self,
159 plugin_name: &str,
160 plugin: &PluginConfig,
161 ) -> anyhow::Result<()> {
162 for mcp_ref in &plugin.mcp_servers {
163 if !self.mcp_servers.contains_key(mcp_ref) {
164 anyhow::bail!(
165 "Plugin '{}': mcp_servers references unknown mcp_server '{}'",
166 plugin_name,
167 mcp_ref
168 );
169 }
170 }
171
172 for agent_ref in &plugin.agents.include {
173 if !self.agents.contains_key(agent_ref) {
174 anyhow::bail!(
175 "Plugin '{}': agents.include references unknown agent '{}'",
176 plugin_name,
177 agent_ref
178 );
179 }
180 }
181
182 self.validate_skills()?;
183
184 Ok(())
185 }
186
187 fn validate_skills(&self) -> anyhow::Result<()> {
188 for (key, skill) in &self.skills.skills {
189 if !skill.id.as_str().is_empty() && skill.id.as_str() != key.as_str() {
190 anyhow::bail!(
191 "Skill map key '{}' does not match skill id '{}'",
192 key,
193 skill.id
194 );
195 }
196
197 for agent_ref in &skill.assigned_agents {
198 if !self.agents.contains_key(agent_ref) {
199 tracing::warn!(
200 skill = %key,
201 agent = %agent_ref,
202 "Skill references agent that is not defined in services config"
203 );
204 }
205 }
206
207 for mcp_ref in &skill.mcp_servers {
208 if !self.mcp_servers.contains_key(mcp_ref) {
209 tracing::warn!(
210 skill = %key,
211 mcp_server = %mcp_ref,
212 "Skill references MCP server that is not defined in services config"
213 );
214 }
215 }
216 }
217
218 Ok(())
219 }
220
221 fn validate_port_conflicts(&self) -> anyhow::Result<()> {
222 let mut seen_ports = HashMap::new();
223
224 for (name, agent) in &self.agents {
225 if let Some(existing) = seen_ports.insert(agent.port, ("agent", name.as_str())) {
226 anyhow::bail!(
227 "Port conflict: {} used by both {} '{}' and agent '{}'",
228 agent.port,
229 existing.0,
230 existing.1,
231 name
232 );
233 }
234 }
235
236 for (name, mcp) in &self.mcp_servers {
237 if mcp.server_type == McpServerType::External {
238 continue;
239 }
240 if let Some(existing) = seen_ports.insert(mcp.port, ("mcp_server", name.as_str())) {
241 anyhow::bail!(
242 "Port conflict: {} used by both {} '{}' and mcp_server '{}'",
243 mcp.port,
244 existing.0,
245 existing.1,
246 name
247 );
248 }
249 }
250
251 Ok(())
252 }
253
254 fn validate_port_ranges(&self) -> anyhow::Result<()> {
255 let (min, max) = self.settings.agent_port_range;
256
257 for (name, agent) in &self.agents {
258 if agent.port < min || agent.port > max {
259 anyhow::bail!(
260 "Agent '{}' port {} is outside allowed range {}-{}",
261 name,
262 agent.port,
263 min,
264 max
265 );
266 }
267 }
268
269 Ok(())
270 }
271
272 fn validate_mcp_port_ranges(&self) -> anyhow::Result<()> {
273 let (min, max) = self.settings.mcp_port_range;
274
275 for (name, mcp) in &self.mcp_servers {
276 if mcp.server_type == McpServerType::External {
277 continue;
278 }
279 if mcp.port < min || mcp.port > max {
280 anyhow::bail!(
281 "MCP server '{}' port {} is outside allowed range {}-{}",
282 name,
283 mcp.port,
284 min,
285 max
286 );
287 }
288 }
289
290 Ok(())
291 }
292
293 fn validate_single_default_agent(&self) -> anyhow::Result<()> {
294 let default_agents: Vec<&str> = self
295 .agents
296 .iter()
297 .filter_map(|(name, agent)| {
298 if agent.default {
299 Some(name.as_str())
300 } else {
301 None
302 }
303 })
304 .collect();
305
306 match default_agents.len() {
307 0 | 1 => Ok(()),
308 _ => anyhow::bail!(
309 "Multiple agents marked as default: {}. Only one agent can have 'default: true'",
310 default_agents.join(", ")
311 ),
312 }
313 }
314}