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 review: ReviewConfig,
34
35 pub thoughts: ThoughtsConfig,
37
38 pub orchestrator: OrchestratorConfig,
40
41 pub web_retrieval: WebRetrievalConfig,
43
44 pub cli_tools: CliToolsConfig,
46
47 pub logging: LoggingConfig,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(default)]
60pub struct SubagentsConfig {
61 pub locator_model: String,
67 pub analyzer_model: String,
69 pub runtime_timeout_secs: u64,
71}
72
73impl Default for SubagentsConfig {
74 fn default() -> Self {
75 Self {
76 locator_model: "claude-haiku-4-5".into(),
77 analyzer_model: "claude-sonnet-4-6".into(),
78 runtime_timeout_secs: 3600,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "lowercase")]
93enum ReasoningEffortLevel {
94 Low,
95 Medium,
96 High,
97 Xhigh,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111#[serde(default)]
112pub struct ReasoningConfig {
113 pub optimizer_model: String,
115 pub executor_model: String,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 #[schemars(with = "Option<ReasoningEffortLevel>")]
120 pub reasoning_effort: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub api_base_url: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
127 pub max_input_tokens: Option<u32>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub max_completion_tokens: Option<u32>,
131 pub executor_timeout_secs: u64,
133 pub empty_response_no_retry_after_secs: u64,
135 pub stream_heartbeat_secs: u64,
137}
138
139impl Default for ReasoningConfig {
140 fn default() -> Self {
141 Self {
142 optimizer_model: "anthropic/claude-sonnet-4.6".into(),
143 executor_model: "openai/gpt-5.2".into(),
144 reasoning_effort: None,
145 api_base_url: None,
146 max_input_tokens: None,
147 max_completion_tokens: Some(128_000),
148 executor_timeout_secs: 2700,
149 empty_response_no_retry_after_secs: 600,
150 stream_heartbeat_secs: 30,
151 }
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163#[serde(default)]
164pub struct OrchestratorConfig {
165 pub session_deadline_secs: u64,
167 pub inactivity_timeout_secs: u64,
169 pub compaction_threshold: f64,
171 pub commands: OrchestratorCommandsConfig,
173 pub agents: OrchestratorAgentsConfig,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
179#[serde(default)]
180pub struct OrchestratorCommandsConfig {
181 pub allow: Vec<String>,
183 pub deny: Vec<String>,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
189#[serde(default)]
190pub struct OrchestratorAgentsConfig {
191 pub allow: Vec<String>,
193 pub deny: Vec<String>,
195}
196
197impl Default for OrchestratorConfig {
198 fn default() -> Self {
199 Self {
200 session_deadline_secs: 3600,
201 inactivity_timeout_secs: 300,
202 compaction_threshold: 0.80,
203 commands: OrchestratorCommandsConfig::default(),
204 agents: OrchestratorAgentsConfig::default(),
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217#[serde(default)]
218pub struct WebRetrievalConfig {
219 pub request_timeout_secs: u64,
221 pub default_max_bytes: u64,
223 pub default_search_results: u32,
225 pub max_search_results: u32,
227 pub summarizer: WebSummarizerConfig,
229}
230
231impl Default for WebRetrievalConfig {
232 fn default() -> Self {
233 Self {
234 request_timeout_secs: 30,
235 default_max_bytes: 5 * 1024 * 1024, default_search_results: 8,
237 max_search_results: 20,
238 summarizer: WebSummarizerConfig::default(),
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245#[serde(default)]
246pub struct WebSummarizerConfig {
247 pub model: String,
249 pub max_tokens: u32,
251 pub temperature: f64,
253}
254
255impl Default for WebSummarizerConfig {
256 fn default() -> Self {
257 Self {
258 model: "claude-haiku-4-5".into(),
259 max_tokens: 300,
260 temperature: 0.2,
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
273#[serde(default)]
274pub struct CliToolsConfig {
275 pub ls_page_size: u32,
277 pub grep_default_limit: u32,
279 pub glob_default_limit: u32,
281 pub max_depth: u32,
283 pub pagination_cache_ttl_secs: u64,
285 pub just_execute_timeout_secs: u64,
287 pub just_search_timeout_secs: u64,
289 #[serde(default)]
291 pub extra_ignore_patterns: Vec<String>,
292}
293
294impl Default for CliToolsConfig {
295 fn default() -> Self {
296 Self {
297 ls_page_size: 100,
298 grep_default_limit: 200,
299 glob_default_limit: 500,
300 max_depth: 10,
301 pagination_cache_ttl_secs: 300,
302 just_execute_timeout_secs: 1800,
303 just_search_timeout_secs: 30,
304 extra_ignore_patterns: vec![],
305 }
306 }
307}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
317#[serde(default)]
318pub struct ServicesConfig {
319 pub anthropic: AnthropicServiceConfig,
321 pub exa: ExaServiceConfig,
323 pub linear: LinearServiceConfig,
325 pub github: GitHubServiceConfig,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
331#[serde(default)]
332pub struct AnthropicServiceConfig {
333 pub base_url: String,
335}
336
337impl Default for AnthropicServiceConfig {
338 fn default() -> Self {
339 Self {
340 base_url: "https://api.anthropic.com".into(),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
347#[serde(default)]
348pub struct ExaServiceConfig {
349 pub base_url: String,
351}
352
353impl Default for ExaServiceConfig {
354 fn default() -> Self {
355 Self {
356 base_url: "https://api.exa.ai".into(),
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363#[serde(default)]
364pub struct LinearServiceConfig {
365 pub base_url: String,
367 pub connect_timeout_secs: u64,
369 pub request_timeout_secs: u64,
371}
372
373impl Default for LinearServiceConfig {
374 fn default() -> Self {
375 Self {
376 base_url: "https://api.linear.app/graphql".into(),
377 connect_timeout_secs: 10,
378 request_timeout_secs: 60,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
385#[serde(default)]
386pub struct GitHubServiceConfig {
387 pub base_url: String,
389 pub total_timeout_secs: u64,
391}
392
393impl Default for GitHubServiceConfig {
394 fn default() -> Self {
395 Self {
396 base_url: "https://api.github.com".into(),
397 total_timeout_secs: 120,
398 }
399 }
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
410#[serde(default)]
411pub struct ReviewConfig {
412 pub run_timeout_secs: u64,
414}
415
416impl Default for ReviewConfig {
417 fn default() -> Self {
418 Self {
419 run_timeout_secs: 1800,
420 }
421 }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
432#[serde(default)]
433pub struct ThoughtsConfig {
434 pub add_reference_timeout_secs: u64,
436}
437
438impl Default for ThoughtsConfig {
439 fn default() -> Self {
440 Self {
441 add_reference_timeout_secs: 600,
442 }
443 }
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
454#[serde(default)]
455pub struct LoggingConfig {
456 pub level: String,
458
459 pub json: bool,
461}
462
463impl Default for LoggingConfig {
464 fn default() -> Self {
465 Self {
466 level: "info".into(),
467 json: false,
468 }
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn test_default_config_serializes() {
478 let config = AgenticConfig::default();
479 let toml_str = toml::to_string_pretty(&config).unwrap();
480 assert!(toml_str.contains("[subagents]"));
481 assert!(toml_str.contains("[reasoning]"));
482 assert!(toml_str.contains("[services.anthropic]"));
484 assert!(toml_str.contains("[services.exa]"));
485 assert!(toml_str.contains("[services.linear]"));
486 assert!(toml_str.contains("[services.github]"));
487 assert!(toml_str.contains("[review]"));
488 assert!(toml_str.contains("[thoughts]"));
489 assert!(toml_str.contains("[orchestrator]"));
490 assert!(toml_str.contains("[orchestrator.commands]"));
491 assert!(toml_str.contains("[orchestrator.agents]"));
492 assert!(toml_str.contains("[web_retrieval]"));
493 assert!(toml_str.contains("[cli_tools]"));
494 assert!(toml_str.contains("[logging]"));
495 assert!(!toml_str.contains("[models]"));
497 }
498
499 #[test]
500 fn test_default_models_use_undated_names() {
501 let subagents = SubagentsConfig::default();
502 assert!(!subagents.locator_model.contains("20"));
503 assert!(!subagents.analyzer_model.contains("20"));
504
505 let reasoning = ReasoningConfig::default();
506 assert!(!reasoning.optimizer_model.contains("20"));
507 assert!(!reasoning.executor_model.contains("20"));
508 }
509
510 #[test]
511 fn test_partial_config_deserializes() {
512 let toml_str = r#"
513[subagents]
514locator_model = "custom-model"
515"#;
516 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
517 assert_eq!(config.subagents.locator_model, "custom-model");
518 assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
520 assert_eq!(config.subagents.runtime_timeout_secs, 3600);
521 assert_eq!(
522 config.services.anthropic.base_url,
523 "https://api.anthropic.com"
524 );
525 assert_eq!(
526 config.services.linear.base_url,
527 "https://api.linear.app/graphql"
528 );
529 assert!(config.orchestrator.commands.allow.is_empty());
530 assert!(config.orchestrator.commands.deny.is_empty());
531 assert!(config.orchestrator.agents.allow.is_empty());
532 assert!(config.orchestrator.agents.deny.is_empty());
533 }
534
535 #[test]
536 fn test_orchestrator_commands_deserialize() {
537 let toml_str = r#"
538[orchestrator.commands]
539allow = ["plan", "research"]
540deny = ["commit"]
541"#;
542
543 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
544
545 assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
546 assert_eq!(config.orchestrator.commands.deny, ["commit"]);
547 }
548
549 #[test]
550 fn test_orchestrator_agents_partial_deserializes_to_empty_lists() {
551 let toml_str = r"
552[orchestrator.agents]
553";
554
555 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
556
557 assert!(config.orchestrator.agents.allow.is_empty());
558 assert!(config.orchestrator.agents.deny.is_empty());
559 }
560
561 #[test]
562 fn test_orchestrator_agents_round_trip() {
563 let toml_str = r#"
564[orchestrator.agents]
565allow = ["Plan", "Research"]
566deny = ["Bash"]
567"#;
568
569 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
570
571 assert_eq!(config.orchestrator.agents.allow, ["Plan", "Research"]);
572 assert_eq!(config.orchestrator.agents.deny, ["Bash"]);
573
574 let serialized = toml::to_string_pretty(&config).unwrap();
575 assert!(serialized.contains("[orchestrator.agents]"));
576 assert!(serialized.contains("allow = ["));
577 assert!(serialized.contains("\"Plan\""));
578 assert!(serialized.contains("\"Research\""));
579 assert!(serialized.contains("deny = ["));
580 assert!(serialized.contains("\"Bash\""));
581 }
582
583 #[test]
584 fn test_schema_field_optional() {
585 let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
586 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
587 assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
588 }
589
590 #[test]
592 fn test_web_retrieval_defaults_match_hardcoded() {
593 let cfg = WebRetrievalConfig::default();
594 assert_eq!(cfg.request_timeout_secs, 30);
595 assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); assert_eq!(cfg.default_search_results, 8);
597 assert_eq!(cfg.max_search_results, 20);
598 assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
599 assert_eq!(cfg.summarizer.max_tokens, 300);
600 assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
601 }
602
603 #[test]
604 fn test_cli_tools_defaults_match_hardcoded() {
605 let cfg = CliToolsConfig::default();
606 assert_eq!(cfg.ls_page_size, 100);
607 assert_eq!(cfg.grep_default_limit, 200);
608 assert_eq!(cfg.glob_default_limit, 500);
609 assert_eq!(cfg.max_depth, 10);
610 assert_eq!(cfg.pagination_cache_ttl_secs, 300);
611 assert_eq!(cfg.just_execute_timeout_secs, 1800);
612 assert_eq!(cfg.just_search_timeout_secs, 30);
613 assert!(cfg.extra_ignore_patterns.is_empty());
614 }
615
616 #[test]
617 fn test_orchestrator_defaults_match_hardcoded() {
618 let cfg = OrchestratorConfig::default();
619 assert_eq!(cfg.session_deadline_secs, 3600);
620 assert_eq!(cfg.inactivity_timeout_secs, 300);
621 assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
622 assert!(cfg.commands.allow.is_empty());
623 assert!(cfg.commands.deny.is_empty());
624 assert!(cfg.agents.allow.is_empty());
625 assert!(cfg.agents.deny.is_empty());
626 }
627
628 #[test]
629 fn test_services_defaults_match_hardcoded() {
630 let cfg = ServicesConfig::default();
631
632 assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
634
635 assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
637
638 assert_eq!(cfg.linear.base_url, "https://api.linear.app/graphql");
640 assert_eq!(cfg.linear.connect_timeout_secs, 10);
641 assert_eq!(cfg.linear.request_timeout_secs, 60);
642
643 assert_eq!(cfg.github.base_url, "https://api.github.com");
645 assert_eq!(cfg.github.total_timeout_secs, 120);
646 }
647
648 #[test]
649 fn test_new_timeout_defaults_match_plan() {
650 let cfg = AgenticConfig::default();
651
652 assert_eq!(cfg.subagents.runtime_timeout_secs, 3600);
653 assert_eq!(cfg.review.run_timeout_secs, 1800);
654 assert_eq!(cfg.thoughts.add_reference_timeout_secs, 600);
655 }
656}