1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AgentConfig {
32 pub name: String,
34
35 #[serde(default)]
37 pub model: Option<String>,
38
39 #[serde(default)]
41 pub instruction: Option<String>,
42
43 #[serde(default)]
45 pub description: Option<String>,
46
47 #[serde(default)]
49 pub tools: Vec<ToolConfig>,
50
51 #[serde(default)]
53 pub sub_agents: Vec<AgentConfig>,
54
55 #[serde(default)]
57 pub temperature: Option<f32>,
58
59 #[serde(default)]
61 pub max_output_tokens: Option<u32>,
62
63 #[serde(default)]
65 pub thinking_budget: Option<u32>,
66
67 #[serde(default)]
69 pub output_key: Option<String>,
70
71 #[serde(default)]
73 pub output_schema: Option<serde_json::Value>,
74
75 #[serde(default)]
77 pub max_llm_calls: Option<u32>,
78
79 #[serde(default = "default_agent_type")]
81 pub agent_type: String,
82
83 #[serde(default)]
85 pub max_iterations: Option<u32>,
86
87 #[serde(default)]
89 pub metadata: HashMap<String, serde_json::Value>,
90
91 #[serde(default)]
93 pub voice: Option<String>,
94
95 #[serde(default)]
97 pub greeting: Option<String>,
98
99 #[serde(default)]
101 pub transcription: Option<bool>,
102
103 #[serde(default)]
105 pub a2a: Option<bool>,
106
107 #[serde(default)]
109 pub env: HashMap<String, String>,
110}
111
112fn default_agent_type() -> String {
113 "llm".to_string()
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ToolConfig {
119 #[serde(default)]
121 pub name: Option<String>,
122
123 #[serde(default)]
125 pub description: Option<String>,
126
127 #[serde(default)]
129 pub builtin: Option<String>,
130
131 #[serde(default)]
133 pub parameters: Option<serde_json::Value>,
134}
135
136#[derive(Debug, thiserror::Error)]
138pub enum AgentConfigError {
139 #[error("IO error: {0}")]
141 Io(#[from] std::io::Error),
142
143 #[error("YAML parse error: {0}")]
145 Yaml(String),
146
147 #[error("TOML parse error: {0}")]
149 Toml(String),
150
151 #[error("JSON parse error: {0}")]
153 Json(#[from] serde_json::Error),
154
155 #[error("Invalid config: {0}")]
157 Invalid(String),
158}
159
160impl AgentConfig {
161 pub fn from_yaml_file(path: &Path) -> Result<Self, AgentConfigError> {
163 let content = std::fs::read_to_string(path)?;
164 Self::from_yaml(&content)
165 }
166
167 pub fn from_yaml(yaml: &str) -> Result<Self, AgentConfigError> {
169 serde_json::from_value(
170 serde_json::to_value(
171 serde_json::from_str::<serde_json::Value>(yaml)
174 .map_err(|e| AgentConfigError::Yaml(e.to_string()))?,
175 )
176 .map_err(|e| AgentConfigError::Yaml(e.to_string()))?,
177 )
178 .map_err(|e| AgentConfigError::Yaml(e.to_string()))
179 }
180
181 pub fn from_json(json: &str) -> Result<Self, AgentConfigError> {
183 Ok(serde_json::from_str(json)?)
184 }
185
186 pub fn from_value(value: serde_json::Value) -> Result<Self, AgentConfigError> {
188 Ok(serde_json::from_value(value)?)
189 }
190
191 pub fn validate(&self) -> Result<(), AgentConfigError> {
193 if self.name.is_empty() {
194 return Err(AgentConfigError::Invalid("Agent name is required".into()));
195 }
196 if let Some(temp) = self.temperature
197 && !(0.0..=2.0).contains(&temp)
198 {
199 return Err(AgentConfigError::Invalid(format!(
200 "Temperature must be 0.0-2.0, got {temp}"
201 )));
202 }
203 for sub in &self.sub_agents {
205 sub.validate()?;
206 }
207 Ok(())
208 }
209
210 pub fn builtin_tools(&self) -> Vec<&str> {
212 self.tools
213 .iter()
214 .filter_map(|t| t.builtin.as_deref())
215 .collect()
216 }
217
218 pub fn is_workflow(&self) -> bool {
220 matches!(self.agent_type.as_str(), "sequential" | "parallel" | "loop")
221 }
222}
223
224pub fn discover_agent_configs(dir: &Path) -> Result<Vec<AgentConfig>, AgentConfigError> {
229 let candidates = ["agent.json", "root_agent.json", "agent.toml"];
230
231 let mut configs = Vec::new();
232 for candidate in &candidates {
233 let path = dir.join(candidate);
234 if path.exists() {
235 let content = std::fs::read_to_string(&path)?;
236 let config: AgentConfig = if candidate.ends_with(".json") {
237 serde_json::from_str(&content)?
238 } else if candidate.ends_with(".toml") {
239 return Err(AgentConfigError::Toml(
241 "TOML parsing requires the gemini-adk-cli-rs crate".into(),
242 ));
243 } else {
244 return Err(AgentConfigError::Yaml(
245 "YAML parsing requires the gemini-adk-cli-rs crate".into(),
246 ));
247 };
248 config.validate()?;
249 configs.push(config);
250 }
251 }
252
253 if let Ok(entries) = std::fs::read_dir(dir) {
255 for entry in entries.flatten() {
256 let path = entry.path();
257 if path.is_dir()
258 && let Ok(sub_configs) = discover_agent_configs(&path)
259 {
260 configs.extend(sub_configs);
261 }
262 }
263 }
264
265 Ok(configs)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn parse_minimal_json_config() {
274 let json = r#"{"name": "test_agent"}"#;
275 let config = AgentConfig::from_json(json).unwrap();
276 assert_eq!(config.name, "test_agent");
277 assert_eq!(config.agent_type, "llm");
278 assert!(config.model.is_none());
279 assert!(config.tools.is_empty());
280 }
281
282 #[test]
283 fn parse_full_json_config() {
284 let json = r#"{
285 "name": "weather_agent",
286 "model": "gemini-2.0-flash",
287 "instruction": "You are a weather assistant.",
288 "description": "Gets weather info",
289 "temperature": 0.3,
290 "max_output_tokens": 1024,
291 "output_key": "weather_result",
292 "max_llm_calls": 10,
293 "tools": [
294 {"name": "get_weather", "description": "Get weather for a city"},
295 {"builtin": "google_search"}
296 ],
297 "sub_agents": [
298 {"name": "forecast", "instruction": "Give forecasts"}
299 ]
300 }"#;
301 let config = AgentConfig::from_json(json).unwrap();
302 assert_eq!(config.name, "weather_agent");
303 assert_eq!(config.model.as_deref(), Some("gemini-2.0-flash"));
304 assert_eq!(config.temperature, Some(0.3));
305 assert_eq!(config.output_key.as_deref(), Some("weather_result"));
306 assert_eq!(config.max_llm_calls, Some(10));
307 assert_eq!(config.tools.len(), 2);
308 assert_eq!(config.sub_agents.len(), 1);
309 assert_eq!(config.builtin_tools(), vec!["google_search"]);
310 }
311
312 #[test]
313 fn validate_empty_name_fails() {
314 let config = AgentConfig::from_json(r#"{"name": ""}"#).unwrap();
315 assert!(config.validate().is_err());
316 }
317
318 #[test]
319 fn validate_bad_temperature_fails() {
320 let config = AgentConfig::from_json(r#"{"name": "test", "temperature": 3.0}"#).unwrap();
321 assert!(config.validate().is_err());
322 }
323
324 #[test]
325 fn validate_good_config_passes() {
326 let config = AgentConfig::from_json(r#"{"name": "test", "temperature": 0.7}"#).unwrap();
327 assert!(config.validate().is_ok());
328 }
329
330 #[test]
331 fn is_workflow_detection() {
332 let sequential =
333 AgentConfig::from_json(r#"{"name": "seq", "agent_type": "sequential"}"#).unwrap();
334 assert!(sequential.is_workflow());
335
336 let llm = AgentConfig::from_json(r#"{"name": "llm"}"#).unwrap();
337 assert!(!llm.is_workflow());
338 }
339
340 #[test]
341 fn tool_config_variants() {
342 let custom = ToolConfig {
343 name: Some("my_tool".into()),
344 description: Some("Does stuff".into()),
345 builtin: None,
346 parameters: Some(serde_json::json!({"type": "object"})),
347 };
348 assert!(custom.name.is_some());
349 assert!(custom.builtin.is_none());
350
351 let builtin = ToolConfig {
352 name: None,
353 description: None,
354 builtin: Some("google_search".into()),
355 parameters: None,
356 };
357 assert!(builtin.builtin.is_some());
358 }
359}