Skip to main content

systemprompt_agent/services/config_authoring/
mod.rs

1//! Authoring workflow for on-disk agent YAML configuration.
2//!
3//! [`AgentConfigAuthoringService`] owns the write path for
4//! `services/agents/<name>.yaml`: input validation, shaping a full
5//! [`AgentConfig`] from an [`AgentCreateRequest`], applying
6//! [`AgentEditRequest`] mutations to an in-memory config, and deleting agent
7//! files through [`ConfigWriter`]. Interactive prompting, profile resolution,
8//! and post-write configuration reloads stay with the caller. All failures
9//! surface as [`ConfigAuthoringError`].
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14mod edit;
15
16use std::fs;
17use std::path::PathBuf;
18
19use systemprompt_identifiers::AgentId;
20use systemprompt_loader::{ConfigWriteError, ConfigWriter};
21use systemprompt_models::modules::ApiPaths;
22use systemprompt_models::profile::ProviderRegistry;
23use systemprompt_models::services::{
24    AgentCardConfig, AgentConfig, AgentMetadataConfig, CapabilitiesConfig, OAuthConfig,
25    PluginComponentRef,
26};
27use thiserror::Error;
28
29pub use edit::AgentEditRequest;
30
31#[derive(Debug, Error)]
32pub enum ConfigAuthoringError {
33    #[error("Agent name must be between 3 and 50 characters")]
34    NameLength,
35
36    #[error("Agent name must be lowercase alphanumeric with underscores only")]
37    NameCharset,
38
39    #[error("Port cannot be 0")]
40    PortZero,
41
42    #[error("Port must be >= 1024 (non-privileged)")]
43    PortPrivileged,
44
45    #[error("Failed to read system prompt file: {path}")]
46    SystemPromptFile {
47        path: String,
48        #[source]
49        source: std::io::Error,
50    },
51
52    #[error("MCP server '{name}' not found in configuration. Available servers: {available}")]
53    UnknownMcpServer { name: String, available: String },
54
55    #[error("Invalid --set format: '{0}'. Expected key=value")]
56    InvalidSetFormat(String),
57
58    #[error("Invalid boolean value for {key}: '{value}'")]
59    InvalidBoolean { key: String, value: String },
60
61    #[error(
62        "Unknown configuration key: '{0}'. Supported keys: card.displayName, card.description, \
63         card.version, endpoint, is_primary, default, dev_only"
64    )]
65    UnknownSetKey(String),
66
67    #[error(transparent)]
68    Write(#[from] ConfigWriteError),
69}
70
71#[derive(Debug, Clone, Default)]
72pub struct AgentCreateRequest {
73    pub name: String,
74    pub port: u16,
75    pub display_name: String,
76    pub description: String,
77    pub system_prompt: String,
78    pub enabled: bool,
79    pub endpoint: Option<String>,
80    pub dev_only: bool,
81    pub is_primary: bool,
82    pub default: bool,
83    pub version: Option<String>,
84    pub icon_url: Option<String>,
85    pub documentation_url: Option<String>,
86    pub streaming: Option<bool>,
87    pub push_notifications: Option<bool>,
88    pub state_transition_history: Option<bool>,
89    pub provider: Option<String>,
90    pub model: Option<String>,
91    pub mcp_servers: Vec<String>,
92    pub skills: Vec<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct AgentConfigAuthoringService {
97    services_dir: PathBuf,
98}
99
100impl AgentConfigAuthoringService {
101    pub fn new(services_dir: impl Into<PathBuf>) -> Self {
102        Self {
103            services_dir: services_dir.into(),
104        }
105    }
106
107    pub fn validate_agent_name(name: &str) -> Result<(), ConfigAuthoringError> {
108        if name.len() < 3 || name.len() > 50 {
109            return Err(ConfigAuthoringError::NameLength);
110        }
111        if !name
112            .chars()
113            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
114        {
115            return Err(ConfigAuthoringError::NameCharset);
116        }
117        Ok(())
118    }
119
120    pub const fn validate_port(port: u16) -> Result<(), ConfigAuthoringError> {
121        if port == 0 {
122            return Err(ConfigAuthoringError::PortZero);
123        }
124        if port < 1024 {
125            return Err(ConfigAuthoringError::PortPrivileged);
126        }
127        Ok(())
128    }
129
130    pub fn resolve_system_prompt(
131        file: Option<&str>,
132        inline: Option<String>,
133        display_name: &str,
134        description: &str,
135    ) -> Result<String, ConfigAuthoringError> {
136        if let Some(path) = file {
137            return fs::read_to_string(path).map_err(|source| {
138                ConfigAuthoringError::SystemPromptFile {
139                    path: path.to_owned(),
140                    source,
141                }
142            });
143        }
144        if let Some(prompt) = inline {
145            return Ok(prompt);
146        }
147        Ok(if description.is_empty() {
148            format!("You are {display_name}.")
149        } else {
150            format!("You are {display_name}. {description}")
151        })
152    }
153
154    pub fn create(&self, request: AgentCreateRequest) -> Result<PathBuf, ConfigAuthoringError> {
155        Self::validate_agent_name(&request.name)?;
156        Self::validate_port(request.port)?;
157        let agent_config = build_agent_config(request);
158        Ok(ConfigWriter::create_agent(
159            &agent_config,
160            &self.services_dir,
161        )?)
162    }
163
164    pub fn delete(&self, name: &str) -> Result<(), ConfigAuthoringError> {
165        Ok(ConfigWriter::delete_agent(name, &self.services_dir)?)
166    }
167}
168
169fn build_agent_config(mut request: AgentCreateRequest) -> AgentConfig {
170    let provider = request.provider.unwrap_or_else(|| "anthropic".to_owned());
171    let model = request
172        .model
173        .unwrap_or_else(|| default_model_for(&provider));
174    let endpoint = match request.endpoint.take() {
175        Some(endpoint) => endpoint,
176        None => ApiPaths::agent_endpoint(&AgentId::new(&request.name)),
177    };
178
179    AgentConfig {
180        name: request.name.clone(),
181        port: request.port,
182        endpoint,
183        enabled: request.enabled,
184        dev_only: request.dev_only,
185        is_primary: request.is_primary,
186        default: request.default,
187        tags: Vec::new(),
188        card: AgentCardConfig {
189            protocol_version: crate::A2A_PROTOCOL_VERSION.to_owned(),
190            name: Some(request.name),
191            display_name: request.display_name,
192            description: request.description,
193            version: request.version.unwrap_or_else(|| "1.0.0".to_owned()),
194            preferred_transport: "JSONRPC".to_owned(),
195            icon_url: request.icon_url,
196            documentation_url: request.documentation_url,
197            provider: None,
198            capabilities: CapabilitiesConfig {
199                streaming: request.streaming.unwrap_or(true),
200                push_notifications: request.push_notifications.unwrap_or(false),
201                state_transition_history: request.state_transition_history.unwrap_or(true),
202            },
203            default_input_modes: vec!["text/plain".to_owned()],
204            default_output_modes: vec!["text/plain".to_owned()],
205            security_schemes: None,
206            security: None,
207            supports_authenticated_extended_card: false,
208        },
209        metadata: AgentMetadataConfig {
210            system_prompt: Some(request.system_prompt),
211            mcp_servers: PluginComponentRef {
212                include: request.mcp_servers,
213                ..Default::default()
214            },
215            skills: PluginComponentRef {
216                include: request.skills,
217                ..Default::default()
218            },
219            provider: Some(provider),
220            model: Some(model),
221            ..Default::default()
222        },
223        oauth: OAuthConfig::default(),
224    }
225}
226
227fn default_model_for(provider: &str) -> String {
228    ProviderRegistry::default_seed()
229        .ok()
230        .and_then(|registry| {
231            registry
232                .find_provider(provider)
233                .and_then(|entry| entry.models.first().map(|m| m.id.as_str().to_owned()))
234        })
235        .unwrap_or_else(|| "claude-sonnet-4-6".to_owned())
236}