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 workspace_tools: WorkspaceToolsConfig,
49
50 pub logging: LoggingConfig,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62#[serde(default)]
63pub struct SubagentsConfig {
64 pub locator_model: String,
70 pub analyzer_model: String,
72 pub runtime_timeout_secs: u64,
74}
75
76impl Default for SubagentsConfig {
77 fn default() -> Self {
78 Self {
79 locator_model: "claude-haiku-4-5".into(),
80 analyzer_model: "claude-sonnet-4-6".into(),
81 runtime_timeout_secs: 3600,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "lowercase")]
96enum ReasoningEffortLevel {
97 Low,
98 Medium,
99 High,
100 Xhigh,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114#[serde(default)]
115pub struct ReasoningConfig {
116 pub optimizer_model: String,
118 pub executor_model: String,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 #[schemars(with = "Option<ReasoningEffortLevel>")]
123 pub reasoning_effort: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub api_base_url: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
130 pub max_input_tokens: Option<u32>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub max_completion_tokens: Option<u32>,
134 pub executor_timeout_secs: u64,
136 pub empty_response_no_retry_after_secs: u64,
138 pub stream_heartbeat_secs: u64,
140}
141
142impl Default for ReasoningConfig {
143 fn default() -> Self {
144 Self {
145 optimizer_model: "anthropic/claude-sonnet-4.6".into(),
146 executor_model: "openai/gpt-5.2".into(),
147 reasoning_effort: None,
148 api_base_url: None,
149 max_input_tokens: None,
150 max_completion_tokens: Some(128_000),
151 executor_timeout_secs: 2700,
152 empty_response_no_retry_after_secs: 600,
153 stream_heartbeat_secs: 30,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166#[serde(default)]
167pub struct OrchestratorConfig {
168 pub session_deadline_secs: u64,
170 pub inactivity_timeout_secs: u64,
172 pub compaction_threshold: f64,
174 pub commands: OrchestratorCommandsConfig,
176 pub agents: OrchestratorAgentsConfig,
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
182#[serde(default)]
183pub struct OrchestratorCommandsConfig {
184 pub allow: Vec<String>,
186 pub deny: Vec<String>,
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
192#[serde(default)]
193pub struct OrchestratorAgentsConfig {
194 pub allow: Vec<String>,
196 pub deny: Vec<String>,
198}
199
200impl Default for OrchestratorConfig {
201 fn default() -> Self {
202 Self {
203 session_deadline_secs: 3600,
204 inactivity_timeout_secs: 300,
205 compaction_threshold: 0.80,
206 commands: OrchestratorCommandsConfig::default(),
207 agents: OrchestratorAgentsConfig::default(),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
220#[serde(default)]
221pub struct WebRetrievalConfig {
222 pub request_timeout_secs: u64,
224 pub default_max_bytes: u64,
226 pub default_search_results: u32,
228 pub max_search_results: u32,
230 pub summarizer: WebSummarizerConfig,
232}
233
234impl Default for WebRetrievalConfig {
235 fn default() -> Self {
236 Self {
237 request_timeout_secs: 30,
238 default_max_bytes: 5 * 1024 * 1024, default_search_results: 8,
240 max_search_results: 20,
241 summarizer: WebSummarizerConfig::default(),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248#[serde(default)]
249pub struct WebSummarizerConfig {
250 pub model: String,
252 pub max_tokens: u32,
254 pub temperature: f64,
256}
257
258impl Default for WebSummarizerConfig {
259 fn default() -> Self {
260 Self {
261 model: "claude-haiku-4-5".into(),
262 max_tokens: 300,
263 temperature: 0.2,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
276#[serde(default)]
277pub struct CliToolsConfig {
278 pub ls_page_size: u32,
280 pub grep_default_limit: u32,
282 pub glob_default_limit: u32,
284 pub max_depth: u32,
286 pub pagination_cache_ttl_secs: u64,
288 pub just_execute_timeout_secs: u64,
290 pub just_search_timeout_secs: u64,
292 #[serde(default)]
294 pub extra_ignore_patterns: Vec<String>,
295}
296
297impl Default for CliToolsConfig {
298 fn default() -> Self {
299 Self {
300 ls_page_size: 100,
301 grep_default_limit: 200,
302 glob_default_limit: 500,
303 max_depth: 10,
304 pagination_cache_ttl_secs: 300,
305 just_execute_timeout_secs: 1800,
306 just_search_timeout_secs: 30,
307 extra_ignore_patterns: vec![],
308 }
309 }
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
320#[serde(default)]
321pub struct WorkspaceToolsConfig {
322 pub workspace_read: bool,
324 pub workspace_todowrite: bool,
326 pub workspace_edit: bool,
328 pub workspace_apply_patch: bool,
330}
331
332#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
340#[serde(default)]
341pub struct ServicesConfig {
342 pub anthropic: AnthropicServiceConfig,
344 pub exa: ExaServiceConfig,
346 pub linear: LinearServiceConfig,
348 pub github: GitHubServiceConfig,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
354#[serde(default)]
355pub struct AnthropicServiceConfig {
356 pub base_url: String,
358}
359
360impl Default for AnthropicServiceConfig {
361 fn default() -> Self {
362 Self {
363 base_url: "https://api.anthropic.com".into(),
364 }
365 }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
370#[serde(default)]
371pub struct ExaServiceConfig {
372 pub base_url: String,
374}
375
376impl Default for ExaServiceConfig {
377 fn default() -> Self {
378 Self {
379 base_url: "https://api.exa.ai".into(),
380 }
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
386#[serde(default)]
387pub struct LinearServiceConfig {
388 pub base_url: String,
390 pub connect_timeout_secs: u64,
392 pub request_timeout_secs: u64,
394}
395
396impl Default for LinearServiceConfig {
397 fn default() -> Self {
398 Self {
399 base_url: "https://api.linear.app/graphql".into(),
400 connect_timeout_secs: 10,
401 request_timeout_secs: 60,
402 }
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
408#[serde(default)]
409pub struct GitHubServiceConfig {
410 pub base_url: String,
412 pub total_timeout_secs: u64,
414}
415
416impl Default for GitHubServiceConfig {
417 fn default() -> Self {
418 Self {
419 base_url: "https://api.github.com".into(),
420 total_timeout_secs: 120,
421 }
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
433#[serde(default)]
434pub struct ReviewConfig {
435 pub run_timeout_secs: u64,
437}
438
439impl Default for ReviewConfig {
440 fn default() -> Self {
441 Self {
442 run_timeout_secs: 1800,
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
455#[serde(default)]
456pub struct ThoughtsConfig {
457 pub add_reference_timeout_secs: u64,
459}
460
461impl Default for ThoughtsConfig {
462 fn default() -> Self {
463 Self {
464 add_reference_timeout_secs: 600,
465 }
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
477#[serde(default)]
478pub struct LoggingConfig {
479 pub level: String,
481
482 pub json: bool,
484}
485
486impl Default for LoggingConfig {
487 fn default() -> Self {
488 Self {
489 level: "info".into(),
490 json: false,
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn test_default_config_serializes() {
501 let config = AgenticConfig::default();
502 let toml_str = toml::to_string_pretty(&config).unwrap();
503 assert!(toml_str.contains("[subagents]"));
504 assert!(toml_str.contains("[reasoning]"));
505 assert!(toml_str.contains("[services.anthropic]"));
507 assert!(toml_str.contains("[services.exa]"));
508 assert!(toml_str.contains("[services.linear]"));
509 assert!(toml_str.contains("[services.github]"));
510 assert!(toml_str.contains("[review]"));
511 assert!(toml_str.contains("[thoughts]"));
512 assert!(toml_str.contains("[orchestrator]"));
513 assert!(toml_str.contains("[orchestrator.commands]"));
514 assert!(toml_str.contains("[orchestrator.agents]"));
515 assert!(toml_str.contains("[web_retrieval]"));
516 assert!(toml_str.contains("[cli_tools]"));
517 assert!(toml_str.contains("[workspace_tools]"));
518 assert!(toml_str.contains("[logging]"));
519 assert!(!toml_str.contains("[models]"));
521 }
522
523 #[test]
524 fn test_default_models_use_undated_names() {
525 let subagents = SubagentsConfig::default();
526 assert!(!subagents.locator_model.contains("20"));
527 assert!(!subagents.analyzer_model.contains("20"));
528
529 let reasoning = ReasoningConfig::default();
530 assert!(!reasoning.optimizer_model.contains("20"));
531 assert!(!reasoning.executor_model.contains("20"));
532 }
533
534 #[test]
535 fn test_partial_config_deserializes() {
536 let toml_str = r#"
537[subagents]
538locator_model = "custom-model"
539"#;
540 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
541 assert_eq!(config.subagents.locator_model, "custom-model");
542 assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
544 assert_eq!(config.subagents.runtime_timeout_secs, 3600);
545 assert_eq!(
546 config.services.anthropic.base_url,
547 "https://api.anthropic.com"
548 );
549 assert_eq!(
550 config.services.linear.base_url,
551 "https://api.linear.app/graphql"
552 );
553 assert!(config.orchestrator.commands.allow.is_empty());
554 assert!(config.orchestrator.commands.deny.is_empty());
555 assert!(config.orchestrator.agents.allow.is_empty());
556 assert!(config.orchestrator.agents.deny.is_empty());
557 }
558
559 #[test]
560 fn test_orchestrator_commands_deserialize() {
561 let toml_str = r#"
562[orchestrator.commands]
563allow = ["plan", "research"]
564deny = ["commit"]
565"#;
566
567 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
568
569 assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
570 assert_eq!(config.orchestrator.commands.deny, ["commit"]);
571 }
572
573 #[test]
574 fn test_orchestrator_agents_partial_deserializes_to_empty_lists() {
575 let toml_str = r"
576[orchestrator.agents]
577";
578
579 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
580
581 assert!(config.orchestrator.agents.allow.is_empty());
582 assert!(config.orchestrator.agents.deny.is_empty());
583 }
584
585 #[test]
586 fn test_orchestrator_agents_round_trip() {
587 let toml_str = r#"
588[orchestrator.agents]
589allow = ["Plan", "Research"]
590deny = ["Bash"]
591"#;
592
593 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
594
595 assert_eq!(config.orchestrator.agents.allow, ["Plan", "Research"]);
596 assert_eq!(config.orchestrator.agents.deny, ["Bash"]);
597
598 let serialized = toml::to_string_pretty(&config).unwrap();
599 assert!(serialized.contains("[orchestrator.agents]"));
600 assert!(serialized.contains("allow = ["));
601 assert!(serialized.contains("\"Plan\""));
602 assert!(serialized.contains("\"Research\""));
603 assert!(serialized.contains("deny = ["));
604 assert!(serialized.contains("\"Bash\""));
605 }
606
607 #[test]
608 fn test_schema_field_optional() {
609 let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
610 let config: AgenticConfig = toml::from_str(toml_str).unwrap();
611 assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
612 }
613
614 #[test]
616 fn test_web_retrieval_defaults_match_hardcoded() {
617 let cfg = WebRetrievalConfig::default();
618 assert_eq!(cfg.request_timeout_secs, 30);
619 assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); assert_eq!(cfg.default_search_results, 8);
621 assert_eq!(cfg.max_search_results, 20);
622 assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
623 assert_eq!(cfg.summarizer.max_tokens, 300);
624 assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
625 }
626
627 #[test]
628 fn test_cli_tools_defaults_match_hardcoded() {
629 let cfg = CliToolsConfig::default();
630 assert_eq!(cfg.ls_page_size, 100);
631 assert_eq!(cfg.grep_default_limit, 200);
632 assert_eq!(cfg.glob_default_limit, 500);
633 assert_eq!(cfg.max_depth, 10);
634 assert_eq!(cfg.pagination_cache_ttl_secs, 300);
635 assert_eq!(cfg.just_execute_timeout_secs, 1800);
636 assert_eq!(cfg.just_search_timeout_secs, 30);
637 assert!(cfg.extra_ignore_patterns.is_empty());
638 }
639
640 #[test]
641 fn test_orchestrator_defaults_match_hardcoded() {
642 let cfg = OrchestratorConfig::default();
643 assert_eq!(cfg.session_deadline_secs, 3600);
644 assert_eq!(cfg.inactivity_timeout_secs, 300);
645 assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
646 assert!(cfg.commands.allow.is_empty());
647 assert!(cfg.commands.deny.is_empty());
648 assert!(cfg.agents.allow.is_empty());
649 assert!(cfg.agents.deny.is_empty());
650 }
651
652 #[test]
653 fn test_services_defaults_match_hardcoded() {
654 let cfg = ServicesConfig::default();
655
656 assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
658
659 assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
661
662 assert_eq!(cfg.linear.base_url, "https://api.linear.app/graphql");
664 assert_eq!(cfg.linear.connect_timeout_secs, 10);
665 assert_eq!(cfg.linear.request_timeout_secs, 60);
666
667 assert_eq!(cfg.github.base_url, "https://api.github.com");
669 assert_eq!(cfg.github.total_timeout_secs, 120);
670 }
671
672 #[test]
673 fn test_new_timeout_defaults_match_plan() {
674 let cfg = AgenticConfig::default();
675
676 assert_eq!(cfg.subagents.runtime_timeout_secs, 3600);
677 assert_eq!(cfg.review.run_timeout_secs, 1800);
678 assert_eq!(cfg.thoughts.add_reference_timeout_secs, 600);
679 }
680
681 #[test]
682 fn test_workspace_tools_defaults_match_plan() {
683 let cfg = AgenticConfig::default();
684
685 assert!(!cfg.workspace_tools.workspace_read);
686 assert!(!cfg.workspace_tools.workspace_todowrite);
687 assert!(!cfg.workspace_tools.workspace_edit);
688 assert!(!cfg.workspace_tools.workspace_apply_patch);
689 }
690}