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/// Agent skill definition for A2A Agent Card.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AgentSkillConfig {
192    pub id: String,
193    pub name: String,
194    pub description: String,
195    #[serde(default)]
196    pub tags: Vec<String>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub examples: Option<Vec<String>>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub input_modes: Option<Vec<String>>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub output_modes: Option<Vec<String>>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub security: Option<Vec<serde_json::Value>>,
205}
206
207/// Information about the organization providing this agent.
208///
209/// This is metadata about the provider, not configuration for calling AI
210/// providers. For AI provider configuration, see
211/// `crates/modules/ai/src/services/providers/provider_factory.
212/// rs::AiProviderConfig`.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct AgentProviderInfo {
215    pub organization: String,
216    pub url: String,
217}
218
219#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct CapabilitiesConfig {
222    #[serde(default = "default_true")]
223    pub streaming: bool,
224    #[serde(default)]
225    pub push_notifications: bool,
226    #[serde(default = "default_true")]
227    pub state_transition_history: bool,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232#[derive(Default)]
233pub struct AgentMetadataConfig {
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub system_prompt: Option<String>,
236    #[serde(default)]
237    pub mcp_servers: Vec<String>,
238    #[serde(default)]
239    pub skills: Vec<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub provider: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub model: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub max_output_tokens: Option<u32>,
246    #[serde(default)]
247    pub tool_model_overrides: ToolModelOverrides,
248}
249
250/// OAuth configuration for A2A agent authentication requirements.
251///
252/// Defines the permissions and audience required to access this agent.
253/// Corresponds to the `security` field in the A2A `AgentCard` specification.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct OAuthConfig {
256    #[serde(default)]
257    pub required: bool,
258    #[serde(default)]
259    pub scopes: Vec<Permission>,
260    #[serde(default = "default_audience")]
261    pub audience: JwtAudience,
262}
263
264impl AgentConfig {
265    pub fn validate(&self, name: &str) -> anyhow::Result<()> {
266        if self.name != name {
267            anyhow::bail!(
268                "Agent config key '{}' does not match name field '{}'",
269                name,
270                self.name
271            );
272        }
273
274        if !self
275            .name
276            .chars()
277            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
278        {
279            anyhow::bail!(
280                "Agent name '{}' must be lowercase alphanumeric with underscores only",
281                self.name
282            );
283        }
284
285        if self.name.len() < 3 || self.name.len() > 50 {
286            anyhow::bail!(
287                "Agent name '{}' must be between 3 and 50 characters",
288                self.name
289            );
290        }
291
292        if self.port == 0 {
293            anyhow::bail!("Agent '{}' has invalid port {}", self.name, self.port);
294        }
295
296        Ok(())
297    }
298
299    pub fn extract_oauth_scopes_from_card(&mut self) {
300        if let Some(security_vec) = &self.card.security {
301            for security_obj in security_vec {
302                if let Some(oauth2_scopes) = security_obj.get("oauth2").and_then(|v| v.as_array()) {
303                    let mut permissions = Vec::new();
304                    for scope_val in oauth2_scopes {
305                        if let Some(scope_str) = scope_val.as_str() {
306                            match scope_str {
307                                "admin" => permissions.push(Permission::Admin),
308                                "user" => permissions.push(Permission::User),
309                                "service" => permissions.push(Permission::Service),
310                                "a2a" => permissions.push(Permission::A2a),
311                                "mcp" => permissions.push(Permission::Mcp),
312                                "anonymous" => permissions.push(Permission::Anonymous),
313                                _ => {},
314                            }
315                        }
316                    }
317                    if !permissions.is_empty() {
318                        self.oauth.scopes = permissions;
319                        self.oauth.required = true;
320                    }
321                }
322            }
323        }
324    }
325
326    pub fn construct_url(&self, base_url: &str) -> String {
327        format!(
328            "{}/api/v1/agents/{}",
329            base_url.trim_end_matches('/'),
330            self.name
331        )
332    }
333}
334
335impl Default for CapabilitiesConfig {
336    fn default() -> Self {
337        Self {
338            streaming: true,
339            push_notifications: false,
340            state_transition_history: true,
341        }
342    }
343}
344
345impl Default for OAuthConfig {
346    fn default() -> Self {
347        Self {
348            required: false,
349            scopes: Vec::new(),
350            audience: JwtAudience::A2a,
351        }
352    }
353}
354
355fn default_transport() -> String {
356    "JSONRPC".to_string()
357}
358
359fn default_input_modes() -> Vec<String> {
360    vec!["text/plain".to_string()]
361}
362
363fn default_output_modes() -> Vec<String> {
364    vec!["text/plain".to_string()]
365}
366
367const fn default_true() -> bool {
368    true
369}
370
371const fn default_audience() -> JwtAudience {
372    JwtAudience::A2a
373}