Skip to main content

systemprompt_models/services/
agent_config.rs

1use super::super::ai::ToolModelOverrides;
2use super::super::auth::{JwtAudience, Permission};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use systemprompt_identifiers::AgentId;
6
7pub const AGENT_CONFIG_FILENAME: &str = "config.yaml";
8pub const DEFAULT_AGENT_SYSTEM_PROMPT_FILE: &str = "system_prompt.md";
9
10fn default_version() -> String {
11    "1.0.0".to_string()
12}
13
14#[derive(Debug, Clone, Deserialize)]
15pub struct DiskAgentConfig {
16    #[serde(default)]
17    pub id: Option<AgentId>,
18    pub name: String,
19    pub display_name: String,
20    pub description: String,
21    #[serde(default = "default_version")]
22    pub version: String,
23    #[serde(default = "default_true")]
24    pub enabled: bool,
25    pub port: u16,
26    #[serde(default)]
27    pub endpoint: Option<String>,
28    #[serde(default)]
29    pub dev_only: bool,
30    #[serde(default)]
31    pub is_primary: bool,
32    #[serde(default)]
33    pub default: bool,
34    #[serde(default)]
35    pub system_prompt_file: Option<String>,
36    #[serde(default)]
37    pub tags: Vec<String>,
38    #[serde(default)]
39    pub category: Option<String>,
40    #[serde(default)]
41    pub mcp_servers: Vec<String>,
42    #[serde(default)]
43    pub skills: Vec<String>,
44    #[serde(default)]
45    pub provider: Option<String>,
46    #[serde(default)]
47    pub model: Option<String>,
48    pub card: AgentCardConfig,
49    #[serde(default)]
50    pub oauth: OAuthConfig,
51}
52
53impl DiskAgentConfig {
54    pub fn system_prompt_file(&self) -> &str {
55        self.system_prompt_file
56            .as_deref()
57            .filter(|s| !s.is_empty())
58            .unwrap_or(DEFAULT_AGENT_SYSTEM_PROMPT_FILE)
59    }
60
61    pub fn to_agent_config(&self, base_url: &str, system_prompt: Option<String>) -> AgentConfig {
62        let endpoint = self.endpoint.clone().unwrap_or_else(|| {
63            format!(
64                "{}/api/v1/agents/{}",
65                base_url.trim_end_matches('/'),
66                self.name
67            )
68        });
69
70        let card_name = self
71            .card
72            .name
73            .clone()
74            .unwrap_or_else(|| self.display_name.clone());
75
76        AgentConfig {
77            name: self.name.clone(),
78            port: self.port,
79            endpoint,
80            enabled: self.enabled,
81            dev_only: self.dev_only,
82            is_primary: self.is_primary,
83            default: self.default,
84            card: AgentCardConfig {
85                name: Some(card_name),
86                ..self.card.clone()
87            },
88            metadata: AgentMetadataConfig {
89                system_prompt,
90                mcp_servers: self.mcp_servers.clone(),
91                skills: self.skills.clone(),
92                provider: self.provider.clone(),
93                model: self.model.clone(),
94                ..Default::default()
95            },
96            oauth: self.oauth.clone(),
97        }
98    }
99
100    pub fn validate(&self, dir_name: &str) -> anyhow::Result<()> {
101        if let Some(id) = &self.id
102            && id.as_str() != dir_name
103        {
104            anyhow::bail!(
105                "Agent config id '{}' does not match directory name '{}'",
106                id,
107                dir_name
108            );
109        }
110
111        if !self
112            .name
113            .chars()
114            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
115        {
116            anyhow::bail!(
117                "Agent name '{}' must be lowercase alphanumeric with underscores only",
118                self.name
119            );
120        }
121
122        if self.name.len() < 3 || self.name.len() > 50 {
123            anyhow::bail!(
124                "Agent name '{}' must be between 3 and 50 characters",
125                self.name
126            );
127        }
128
129        if self.port == 0 {
130            anyhow::bail!("Agent '{}' has invalid port {}", self.name, self.port);
131        }
132
133        if self.display_name.is_empty() {
134            anyhow::bail!("Agent '{}' display_name must not be empty", self.name);
135        }
136
137        Ok(())
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct AgentConfig {
143    pub name: String,
144    pub port: u16,
145    pub endpoint: String,
146    pub enabled: bool,
147    #[serde(default)]
148    pub dev_only: bool,
149    #[serde(default)]
150    pub is_primary: bool,
151    #[serde(default)]
152    pub default: bool,
153    pub card: AgentCardConfig,
154    pub metadata: AgentMetadataConfig,
155    #[serde(default)]
156    pub oauth: OAuthConfig,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct AgentCardConfig {
162    pub protocol_version: String,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub name: Option<String>,
165    pub display_name: String,
166    pub description: String,
167    pub version: String,
168    #[serde(default = "default_transport")]
169    pub preferred_transport: String,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub icon_url: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub documentation_url: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub provider: Option<AgentProviderInfo>,
176    #[serde(default)]
177    pub capabilities: CapabilitiesConfig,
178    #[serde(default = "default_input_modes")]
179    pub default_input_modes: Vec<String>,
180    #[serde(default = "default_output_modes")]
181    pub default_output_modes: Vec<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub security_schemes: Option<serde_json::Value>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub security: Option<Vec<serde_json::Value>>,
186    #[serde(default)]
187    pub skills: Vec<AgentSkillConfig>,
188    #[serde(default)]
189    pub supports_authenticated_extended_card: bool,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct AgentSkillConfig {
194    pub id: systemprompt_identifiers::SkillId,
195    pub name: String,
196    pub description: String,
197    #[serde(default)]
198    pub tags: Vec<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub examples: Option<Vec<String>>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub input_modes: Option<Vec<String>>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub output_modes: Option<Vec<String>>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub security: Option<Vec<serde_json::Value>>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct AgentProviderInfo {
211    pub organization: String,
212    pub url: String,
213}
214
215#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct CapabilitiesConfig {
218    #[serde(default = "default_true")]
219    pub streaming: bool,
220    #[serde(default)]
221    pub push_notifications: bool,
222    #[serde(default = "default_true")]
223    pub state_transition_history: bool,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228#[derive(Default)]
229pub struct AgentMetadataConfig {
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub system_prompt: Option<String>,
232    #[serde(default)]
233    pub mcp_servers: Vec<String>,
234    #[serde(default)]
235    pub skills: Vec<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub provider: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub model: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub max_output_tokens: Option<u32>,
242    #[serde(default)]
243    pub tool_model_overrides: ToolModelOverrides,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct OAuthConfig {
248    #[serde(default)]
249    pub required: bool,
250    #[serde(default)]
251    pub scopes: Vec<Permission>,
252    #[serde(default = "default_audience")]
253    pub audience: JwtAudience,
254}
255
256impl AgentConfig {
257    pub fn validate(&self, name: &str) -> anyhow::Result<()> {
258        if self.name != name {
259            anyhow::bail!(
260                "Agent config key '{}' does not match name field '{}'",
261                name,
262                self.name
263            );
264        }
265
266        if !self
267            .name
268            .chars()
269            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
270        {
271            anyhow::bail!(
272                "Agent name '{}' must be lowercase alphanumeric with underscores only",
273                self.name
274            );
275        }
276
277        if self.name.len() < 3 || self.name.len() > 50 {
278            anyhow::bail!(
279                "Agent name '{}' must be between 3 and 50 characters",
280                self.name
281            );
282        }
283
284        if self.port == 0 {
285            anyhow::bail!("Agent '{}' has invalid port {}", self.name, self.port);
286        }
287
288        Ok(())
289    }
290
291    pub fn extract_oauth_scopes_from_card(&mut self) {
292        if let Some(security_vec) = &self.card.security {
293            for security_obj in security_vec {
294                if let Some(oauth2_scopes) = security_obj.get("oauth2").and_then(|v| v.as_array()) {
295                    let mut permissions = Vec::new();
296                    for scope_val in oauth2_scopes {
297                        if let Some(scope_str) = scope_val.as_str() {
298                            match scope_str {
299                                "admin" => permissions.push(Permission::Admin),
300                                "user" => permissions.push(Permission::User),
301                                "service" => permissions.push(Permission::Service),
302                                "a2a" => permissions.push(Permission::A2a),
303                                "mcp" => permissions.push(Permission::Mcp),
304                                "anonymous" => permissions.push(Permission::Anonymous),
305                                _ => {},
306                            }
307                        }
308                    }
309                    if !permissions.is_empty() {
310                        self.oauth.scopes = permissions;
311                        self.oauth.required = true;
312                    }
313                }
314            }
315        }
316    }
317
318    pub fn construct_url(&self, base_url: &str) -> String {
319        format!(
320            "{}/api/v1/agents/{}",
321            base_url.trim_end_matches('/'),
322            self.name
323        )
324    }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
328pub struct AgentSummary {
329    pub agent_id: AgentId,
330    pub name: String,
331    pub display_name: String,
332    pub port: u16,
333    pub enabled: bool,
334    pub is_primary: bool,
335    pub is_default: bool,
336    #[serde(default)]
337    pub tags: Vec<String>,
338}
339
340impl AgentSummary {
341    pub fn from_config(name: &str, config: &AgentConfig) -> Self {
342        Self {
343            agent_id: AgentId::new(name),
344            name: name.to_string(),
345            display_name: config.card.display_name.clone(),
346            port: config.port,
347            enabled: config.enabled,
348            is_primary: config.is_primary,
349            is_default: config.default,
350            tags: Vec::new(),
351        }
352    }
353}
354
355impl From<&AgentConfig> for AgentSummary {
356    fn from(config: &AgentConfig) -> Self {
357        Self {
358            agent_id: AgentId::new(config.name.clone()),
359            name: config.name.clone(),
360            display_name: config.card.display_name.clone(),
361            port: config.port,
362            enabled: config.enabled,
363            is_primary: config.is_primary,
364            is_default: config.default,
365            tags: Vec::new(),
366        }
367    }
368}
369
370impl Default for CapabilitiesConfig {
371    fn default() -> Self {
372        Self {
373            streaming: true,
374            push_notifications: false,
375            state_transition_history: true,
376        }
377    }
378}
379
380impl Default for OAuthConfig {
381    fn default() -> Self {
382        Self {
383            required: false,
384            scopes: Vec::new(),
385            audience: JwtAudience::A2a,
386        }
387    }
388}
389
390fn default_transport() -> String {
391    "JSONRPC".to_string()
392}
393
394fn default_input_modes() -> Vec<String> {
395    vec!["text/plain".to_string()]
396}
397
398fn default_output_modes() -> Vec<String> {
399    vec!["text/plain".to_string()]
400}
401
402const fn default_true() -> bool {
403    true
404}
405
406const fn default_audience() -> JwtAudience {
407    JwtAudience::A2a
408}