Skip to main content

systemprompt_models/services/
mod.rs

1pub 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}