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