Skip to main content

systemprompt_models/services/
mod.rs

1//! `services` module — see crate-level docs for context.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6pub mod agent_config;
7pub mod ai;
8pub mod artifacts;
9pub mod bridge_policy;
10pub mod external_agent;
11pub mod frontmatter;
12pub mod gateway;
13pub mod hooks;
14mod includable;
15pub mod marketplace;
16pub mod mcp;
17pub mod plugin;
18pub mod providers;
19pub mod runtime;
20pub mod scheduler;
21pub mod settings;
22pub mod skills;
23pub mod slack;
24pub mod system_admin;
25pub mod teams;
26mod validation;
27
28pub use includable::IncludableString;
29
30pub use agent_config::{
31    AGENT_CONFIG_FILENAME, AgentCardConfig, AgentConfig, AgentMetadataConfig, AgentProviderInfo,
32    AgentSummary, CapabilitiesConfig, DEFAULT_AGENT_SYSTEM_PROMPT_FILE, DiskAgentConfig,
33    OAuthConfig,
34};
35pub use ai::{
36    AiConfig, AiProviderConfig, HistoryConfig, McpConfig, ModelCapabilities, ModelDefinition,
37    ModelGovernance, ModelLimits, ModelPricing, ResilienceSettings, SamplingConfig,
38};
39pub use artifacts::{ARTIFACT_CONFIG_FILENAME, DEFAULT_ARTIFACT_CONTENT_FILE, DiskArtifactConfig};
40pub use bridge_policy::BridgePolicyConfig;
41pub use external_agent::{ExternalAgentConfig, ExternalAgentKind};
42pub use frontmatter::{Frontmatter, split_frontmatter, strip_frontmatter};
43pub use gateway::{
44    BridgeReleasesSpec, GatewayConfig, GatewayConfigSpec, GatewayProfileError, GatewayResult,
45    GatewayRoute, GatewayState, OverrideRuleAction, ResponseFormatKind, RouteMatch,
46    RouteRequirements, SystemPromptRule, slugify_pattern, synthesize_route_id,
47};
48pub use hooks::{
49    DiskHookConfig, HOOK_CONFIG_FILENAME, HookAction, HookCategory, HookEvent, HookEventsConfig,
50    HookMatcher, HookType,
51};
52pub use marketplace::{
53    MarketplaceAccess, MarketplaceConfig, MarketplaceConfigFile, MarketplaceMemberKind,
54    MarketplaceVisibility,
55};
56pub use mcp::McpServerSummary;
57pub use plugin::{
58    ComponentFilter, ComponentSource, PluginAuthor, PluginComponentRef, PluginConfig,
59    PluginConfigFile, PluginHooksRef, PluginScript, PluginSummary, PluginVariableDef,
60};
61pub use providers::{
62    ApiSurface, ProviderEntry, ProviderModel, ProviderRegistry, ProviderRegistryError,
63    ProviderRegistryResult, WireProtocol,
64};
65pub use runtime::{RuntimeStatus, ServiceType};
66pub use scheduler::*;
67pub use settings::*;
68pub use skills::{
69    DEFAULT_SKILL_CONTENT_FILE, DiskSkillConfig, SKILL_CONFIG_FILENAME, SkillConfig, SkillDetail,
70    SkillSummary, SkillsConfig,
71};
72pub use slack::{SlackAppConfig, SlackAuthzConfig};
73pub use system_admin::{SystemAdmin, SystemAdminConfig};
74pub use systemprompt_provider_contracts::{BrandingConfig, WebConfig};
75pub use teams::{TeamsAppConfig, TeamsAuthzConfig};
76
77use crate::errors::ConfigValidationError;
78use crate::mcp::{Deployment, McpServerType};
79use serde::{Deserialize, Serialize};
80use std::collections::HashMap;
81use systemprompt_identifiers::{ExternalAgentId, MarketplaceId};
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct ServicesConfig {
86    #[serde(default)]
87    pub includes: Vec<String>,
88    #[serde(default)]
89    pub settings: Settings,
90    #[serde(default)]
91    pub agents: HashMap<String, AgentConfig>,
92    #[serde(default)]
93    pub mcp_servers: HashMap<String, Deployment>,
94    #[serde(default)]
95    pub scheduler: Option<SchedulerConfig>,
96    #[serde(default)]
97    pub ai: AiConfig,
98    #[serde(default)]
99    pub web: Option<WebConfig>,
100    #[serde(default)]
101    pub plugins: HashMap<String, PluginConfig>,
102    #[serde(default)]
103    pub marketplaces: HashMap<MarketplaceId, MarketplaceConfig>,
104    #[serde(default)]
105    pub skills: SkillsConfig,
106    #[serde(default)]
107    pub external_agents: HashMap<ExternalAgentId, ExternalAgentConfig>,
108    #[serde(default)]
109    pub slack_apps: HashMap<String, SlackAppConfig>,
110    #[serde(default)]
111    pub teams_apps: HashMap<String, TeamsAppConfig>,
112    #[serde(default)]
113    pub bridge_policy: Option<BridgePolicyConfig>,
114    #[serde(default)]
115    pub providers: ProviderRegistry,
116    #[serde(default)]
117    pub gateway: Option<GatewayState>,
118}
119
120impl ServicesConfig {
121    pub fn apply_port_offset(&mut self, offset: u16) -> Result<(), ConfigValidationError> {
122        if offset == 0 {
123            return Ok(());
124        }
125
126        let shift = |port: u16, what: &str| {
127            port.checked_add(offset).ok_or_else(|| {
128                ConfigValidationError::invalid_field(format!(
129                    "{what} port {port} shifted by services.port_offset {offset} exceeds 65535"
130                ))
131            })
132        };
133
134        for (name, agent) in &mut self.agents {
135            agent.port = shift(agent.port, &format!("Agent '{name}'"))?;
136        }
137
138        for (name, mcp) in &mut self.mcp_servers {
139            if mcp.server_type == McpServerType::External {
140                continue;
141            }
142            mcp.port = shift(mcp.port, &format!("MCP server '{name}'"))?;
143        }
144
145        self.settings.agent_port_range = (
146            shift(self.settings.agent_port_range.0, "agent_port_range lower")?,
147            shift(self.settings.agent_port_range.1, "agent_port_range upper")?,
148        );
149        self.settings.mcp_port_range = (
150            shift(self.settings.mcp_port_range.0, "mcp_port_range lower")?,
151            shift(self.settings.mcp_port_range.1, "mcp_port_range upper")?,
152        );
153
154        Ok(())
155    }
156
157    pub fn validate(&self) -> Result<(), ConfigValidationError> {
158        self.validate_ports()?;
159        self.validate_single_default_agent()?;
160
161        for (name, agent) in &self.agents {
162            agent.validate(name)?;
163        }
164
165        for (name, mcp) in &self.mcp_servers {
166            mcp.validate(name)?;
167        }
168
169        self.validate_skills()?;
170
171        for (name, plugin) in &self.plugins {
172            plugin.validate(name)?;
173            self.validate_plugin_bindings(name, plugin)?;
174        }
175
176        self.validate_single_governance_hook_owner()?;
177
178        for (id, marketplace) in &self.marketplaces {
179            marketplace.validate(id.as_str())?;
180            self.validate_marketplace_bindings(id.as_str(), marketplace)?;
181        }
182
183        self.validate_default_marketplace_selector()?;
184
185        for (name, app) in &self.slack_apps {
186            app.validate(name)?;
187        }
188
189        for (name, app) in &self.teams_apps {
190            app.validate(name)?;
191        }
192
193        self.validate_providers_and_gateway()
194    }
195
196    // Why: the registry is the authority for connectivity and the gateway only
197    // references into it, so both are checked here in that order — a route
198    // naming an undeclared provider is a services-tree error, not a boot-time
199    // surprise. A gateway still in `Spec` form is validated as it would resolve;
200    // the loader stores only the resolved form.
201    fn validate_providers_and_gateway(&self) -> Result<(), ConfigValidationError> {
202        self.providers
203            .validate()
204            .map_err(|e| ConfigValidationError::invalid_field(format!("providers: {e}")))?;
205        match &self.gateway {
206            Some(GatewayState::Resolved(config)) => config.validate(&self.providers),
207            Some(GatewayState::Spec(spec)) => spec.clone().resolve().validate(&self.providers),
208            None => Ok(()),
209        }
210        .map_err(|e| ConfigValidationError::invalid_field(format!("gateway: {e}")))
211    }
212
213    #[must_use]
214    pub fn gateway_config(&self) -> Option<&GatewayConfig> {
215        self.gateway.as_ref().and_then(GatewayState::resolved)
216    }
217
218    #[must_use]
219    pub fn marketplace_plugin_configs(
220        &self,
221        marketplace: &MarketplaceConfig,
222    ) -> Vec<&PluginConfig> {
223        self.plugins
224            .values()
225            .filter(|p| p.enabled)
226            .filter(|p| {
227                marketplace.plugins.include.is_empty()
228                    || marketplace
229                        .plugins
230                        .include
231                        .iter()
232                        .any(|inc| inc == p.id.as_str())
233            })
234            .collect()
235    }
236
237    #[must_use]
238    pub fn plugin_selected_skill_ids(
239        &self,
240        plugin: &PluginConfig,
241    ) -> std::collections::BTreeSet<String> {
242        let mut ids: std::collections::BTreeSet<String> = match plugin.skills.source {
243            ComponentSource::Explicit => plugin.skills.include.iter().cloned().collect(),
244            ComponentSource::Instance => self
245                .skills
246                .skills
247                .keys()
248                .filter(|k| !plugin.skills.exclude.iter().any(|ex| ex == *k))
249                .cloned()
250                .collect(),
251        };
252
253        let selected_agent = |name: &str| match plugin.agents.source {
254            ComponentSource::Explicit => plugin.agents.include.iter().any(|inc| inc == name),
255            ComponentSource::Instance => !plugin.agents.exclude.iter().any(|ex| ex == name),
256        };
257        for (name, agent) in &self.agents {
258            if selected_agent(name) {
259                ids.extend(agent.metadata.skills.include.iter().cloned());
260            }
261        }
262
263        ids
264    }
265
266    #[must_use]
267    pub fn marketplace_skill_members(
268        &self,
269        marketplace: &MarketplaceConfig,
270    ) -> std::collections::BTreeSet<String> {
271        self.marketplace_plugin_configs(marketplace)
272            .into_iter()
273            .flat_map(|plugin| self.plugin_selected_skill_ids(plugin))
274            .collect()
275    }
276}