1use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::Serialize;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
16#[serde(default)]
17pub struct AgenticConfig {
18 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
21 pub schema: Option<String>,
22
23 pub subagents: SubagentsConfig,
25
26 pub reasoning: ReasoningConfig,
28
29 pub services: ServicesConfig,
31
32 pub orchestrator: OrchestratorConfig,
34
35 pub web_retrieval: WebRetrievalConfig,
37
38 pub cli_tools: CliToolsConfig,
40
41 pub logging: LoggingConfig,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53#[serde(default)]
54pub struct SubagentsConfig {
55 pub locator_model: String,
61 pub analyzer_model: String,
63}
64
65impl Default for SubagentsConfig {
66 fn default() -> Self {
67 Self {
68 locator_model: "claude-haiku-4-5".into(),
69 analyzer_model: "claude-sonnet-4-6".into(),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "lowercase")]
84enum ReasoningEffortLevel {
85 Low,
86 Medium,
87 High,
88 Xhigh,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102#[serde(default)]
103pub struct ReasoningConfig {
104 pub optimizer_model: String,
106 pub executor_model: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 #[schemars(with = "Option<ReasoningEffortLevel>")]
111 pub reasoning_effort: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub api_base_url: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
118 pub max_input_tokens: Option<u32>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub max_completion_tokens: Option<u32>,
122 pub executor_timeout_secs: u64,
124 pub empty_response_no_retry_after_secs: u64,
126 pub stream_heartbeat_secs: u64,
128}
129
130impl Default for ReasoningConfig {
131 fn default() -> Self {
132 Self {
133 optimizer_model: "anthropic/claude-sonnet-4.6".into(),
134 executor_model: "openai/gpt-5.2".into(),
135 reasoning_effort: None,
136 api_base_url: None,
137 max_input_tokens: None,
138 max_completion_tokens: Some(128_000),
139 executor_timeout_secs: 2700,
140 empty_response_no_retry_after_secs: 600,
141 stream_heartbeat_secs: 30,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154#[serde(default)]
155pub struct OrchestratorConfig {
156 pub session_deadline_secs: u64,
158 pub inactivity_timeout_secs: u64,
160 pub compaction_threshold: f64,
162 pub commands: OrchestratorCommandsConfig,
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
168#[serde(default)]
169pub struct OrchestratorCommandsConfig {
170 pub allow: Vec<String>,
172 pub deny: Vec<String>,
174}
175
176impl Default for OrchestratorConfig {
177 fn default() -> Self {
178 Self {
179 session_deadline_secs: 3600,
180 inactivity_timeout_secs: 300,
181 compaction_threshold: 0.80,
182 commands: OrchestratorCommandsConfig::default(),
183 }
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195#[serde(default)]
196pub struct WebRetrievalConfig {
197 pub request_timeout_secs: u64,
199 pub default_max_bytes: u64,
201 pub default_search_results: u32,
203 pub max_search_results: u32,
205 pub summarizer: WebSummarizerConfig,
207}
208
209impl Default for WebRetrievalConfig {
210 fn default() -> Self {
211 Self {
212 request_timeout_secs: 30,
213 default_max_bytes: 5 * 1024 * 1024, default_search_results: 8,
215 max_search_results: 20,
216 summarizer: WebSummarizerConfig::default(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223#[serde(default)]
224pub struct WebSummarizerConfig {
225 pub model: String,
227 pub max_tokens: u32,
229 pub temperature: f64,
231}
232
233impl Default for WebSummarizerConfig {
234 fn default() -> Self {
235 Self {
236 model: "claude-haiku-4-5".into(),
237 max_tokens: 300,
238 temperature: 0.2,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
251#[serde(default)]
252pub struct CliToolsConfig {
253 pub ls_page_size: u32,
255 pub grep_default_limit: u32,
257 pub glob_default_limit: u32,
259 pub max_depth: u32,
261 pub pagination_cache_ttl_secs: u64,
263 #[serde(default)]
265 pub extra_ignore_patterns: Vec<String>,
266}
267
268impl Default for CliToolsConfig {
269 fn default() -> Self {
270 Self {
271 ls_page_size: 100,
272 grep_default_limit: 200,
273 glob_default_limit: 500,
274 max_depth: 10,
275 pagination_cache_ttl_secs: 300,
276 extra_ignore_patterns: vec![],
277 }
278 }
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
289#[serde(default)]
290pub struct ServicesConfig {
291 pub anthropic: AnthropicServiceConfig,
293 pub exa: ExaServiceConfig,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
299#[serde(default)]
300pub struct AnthropicServiceConfig {
301 pub base_url: String,
303}
304
305impl Default for AnthropicServiceConfig {
306 fn default() -> Self {
307 Self {
308 base_url: "https://api.anthropic.com".into(),
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315#[serde(default)]
316pub struct ExaServiceConfig {
317 pub base_url: String,
319}
320
321impl Default for ExaServiceConfig {
322 fn default() -> Self {
323 Self {
324 base_url: "https://api.exa.ai".into(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
337#[serde(default)]
338pub struct LoggingConfig {
339 pub level: String,
341
342 pub json: bool,
344}
345
346impl Default for LoggingConfig {
347 fn default() -> Self {
348 Self {
349 level: "info".into(),
350 json: false,
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_default_config_serializes() {
361 let config = AgenticConfig::default();
362 let toml_str = toml::to_string_pretty(&config).unwrap();
363 assert!(toml_str.contains("[subagents]"));
364 assert!(toml_str.contains("[reasoning]"));
365 assert!(toml_str.contains("[services.anthropic]"));
367 assert!(toml_str.contains("[services.exa]"));
368 assert!(toml_str.contains("[orchestrator]"));
369 assert!(toml_str.contains("[orchestrator.commands]"));
370 assert!(toml_str.contains("[web_retrieval]"));
371 assert!(toml_str.contains("[cli_tools]"));
372 assert!(toml_str.contains("[logging]"));
373 assert!(!toml_str.contains("[thoughts]"));
375 assert!(!toml_str.contains("[models]"));
376 }
377
378 #[test]
379 fn test_default_models_use_undated_names() {
380 let subagents = SubagentsConfig::default();
381 assert!(!subagents.locator_model.contains("20"));
382 assert!(!subagents.analyzer_model.contains("20"));
383
384 let reasoning = ReasoningConfig::default();
385 assert!(!reasoning.optimizer_model.contains("20"));
386 assert!(!reasoning.executor_model.contains("20"));
387 }
388
389 #[test]
390 fn test_partial_config_deserializes() {
391 let toml_str = r#"
392[subagents]
393locator_model = "custom-model"
394"#;
395 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
396 assert_eq!(config.subagents.locator_model, "custom-model");
397 assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
399 assert_eq!(
400 config.services.anthropic.base_url,
401 "https://api.anthropic.com"
402 );
403 assert!(config.orchestrator.commands.allow.is_empty());
404 assert!(config.orchestrator.commands.deny.is_empty());
405 }
406
407 #[test]
408 fn test_orchestrator_commands_deserialize() {
409 let toml_str = r#"
410[orchestrator.commands]
411allow = ["plan", "research"]
412deny = ["commit"]
413"#;
414
415 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
416
417 assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
418 assert_eq!(config.orchestrator.commands.deny, ["commit"]);
419 }
420
421 #[test]
422 fn test_schema_field_optional() {
423 let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
424 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
425 assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
426 }
427
428 #[test]
430 fn test_web_retrieval_defaults_match_hardcoded() {
431 let cfg = WebRetrievalConfig::default();
432 assert_eq!(cfg.request_timeout_secs, 30);
433 assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); assert_eq!(cfg.default_search_results, 8);
435 assert_eq!(cfg.max_search_results, 20);
436 assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
437 assert_eq!(cfg.summarizer.max_tokens, 300);
438 assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
439 }
440
441 #[test]
442 fn test_cli_tools_defaults_match_hardcoded() {
443 let cfg = CliToolsConfig::default();
444 assert_eq!(cfg.ls_page_size, 100);
445 assert_eq!(cfg.grep_default_limit, 200);
446 assert_eq!(cfg.glob_default_limit, 500);
447 assert_eq!(cfg.max_depth, 10);
448 assert_eq!(cfg.pagination_cache_ttl_secs, 300);
449 assert!(cfg.extra_ignore_patterns.is_empty());
450 }
451
452 #[test]
453 fn test_orchestrator_defaults_match_hardcoded() {
454 let cfg = OrchestratorConfig::default();
455 assert_eq!(cfg.session_deadline_secs, 3600);
456 assert_eq!(cfg.inactivity_timeout_secs, 300);
457 assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
458 assert!(cfg.commands.allow.is_empty());
459 assert!(cfg.commands.deny.is_empty());
460 }
461
462 #[test]
463 fn test_services_defaults_match_hardcoded() {
464 let cfg = ServicesConfig::default();
465
466 assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
468
469 assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
471 }
472}