systemprompt_models/services/
agent_config.rs1use 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)]
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#[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#[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}