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