Skip to main content

systemprompt_models/services/
agent_config.rs

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