systemprompt_agent/services/config_authoring/
mod.rs1mod 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}