use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct AgenticConfig {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub subagents: SubagentsConfig,
pub reasoning: ReasoningConfig,
pub services: ServicesConfig,
pub review: ReviewConfig,
pub thoughts: ThoughtsConfig,
pub orchestrator: OrchestratorConfig,
pub web_retrieval: WebRetrievalConfig,
pub cli_tools: CliToolsConfig,
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SubagentsConfig {
pub locator_model: String,
pub analyzer_model: String,
pub runtime_timeout_secs: u64,
}
impl Default for SubagentsConfig {
fn default() -> Self {
Self {
locator_model: "claude-haiku-4-5".into(),
analyzer_model: "claude-sonnet-4-6".into(),
runtime_timeout_secs: 3600,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum ReasoningEffortLevel {
Low,
Medium,
High,
Xhigh,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ReasoningConfig {
pub optimizer_model: String,
pub executor_model: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<ReasoningEffortLevel>")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
pub executor_timeout_secs: u64,
pub empty_response_no_retry_after_secs: u64,
pub stream_heartbeat_secs: u64,
}
impl Default for ReasoningConfig {
fn default() -> Self {
Self {
optimizer_model: "anthropic/claude-sonnet-4.6".into(),
executor_model: "openai/gpt-5.2".into(),
reasoning_effort: None,
api_base_url: None,
max_input_tokens: None,
max_completion_tokens: Some(128_000),
executor_timeout_secs: 2700,
empty_response_no_retry_after_secs: 600,
stream_heartbeat_secs: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct OrchestratorConfig {
pub session_deadline_secs: u64,
pub inactivity_timeout_secs: u64,
pub compaction_threshold: f64,
pub commands: OrchestratorCommandsConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct OrchestratorCommandsConfig {
pub allow: Vec<String>,
pub deny: Vec<String>,
}
impl Default for OrchestratorConfig {
fn default() -> Self {
Self {
session_deadline_secs: 3600,
inactivity_timeout_secs: 300,
compaction_threshold: 0.80,
commands: OrchestratorCommandsConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct WebRetrievalConfig {
pub request_timeout_secs: u64,
pub default_max_bytes: u64,
pub default_search_results: u32,
pub max_search_results: u32,
pub summarizer: WebSummarizerConfig,
}
impl Default for WebRetrievalConfig {
fn default() -> Self {
Self {
request_timeout_secs: 30,
default_max_bytes: 5 * 1024 * 1024, default_search_results: 8,
max_search_results: 20,
summarizer: WebSummarizerConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct WebSummarizerConfig {
pub model: String,
pub max_tokens: u32,
pub temperature: f64,
}
impl Default for WebSummarizerConfig {
fn default() -> Self {
Self {
model: "claude-haiku-4-5".into(),
max_tokens: 300,
temperature: 0.2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct CliToolsConfig {
pub ls_page_size: u32,
pub grep_default_limit: u32,
pub glob_default_limit: u32,
pub max_depth: u32,
pub pagination_cache_ttl_secs: u64,
pub just_execute_timeout_secs: u64,
pub just_search_timeout_secs: u64,
#[serde(default)]
pub extra_ignore_patterns: Vec<String>,
}
impl Default for CliToolsConfig {
fn default() -> Self {
Self {
ls_page_size: 100,
grep_default_limit: 200,
glob_default_limit: 500,
max_depth: 10,
pagination_cache_ttl_secs: 300,
just_execute_timeout_secs: 1800,
just_search_timeout_secs: 30,
extra_ignore_patterns: vec![],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ServicesConfig {
pub anthropic: AnthropicServiceConfig,
pub exa: ExaServiceConfig,
pub linear: LinearServiceConfig,
pub github: GitHubServiceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct AnthropicServiceConfig {
pub base_url: String,
}
impl Default for AnthropicServiceConfig {
fn default() -> Self {
Self {
base_url: "https://api.anthropic.com".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ExaServiceConfig {
pub base_url: String,
}
impl Default for ExaServiceConfig {
fn default() -> Self {
Self {
base_url: "https://api.exa.ai".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct LinearServiceConfig {
pub base_url: String,
pub connect_timeout_secs: u64,
pub request_timeout_secs: u64,
}
impl Default for LinearServiceConfig {
fn default() -> Self {
Self {
base_url: "https://api.linear.app/graphql".into(),
connect_timeout_secs: 10,
request_timeout_secs: 60,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct GitHubServiceConfig {
pub base_url: String,
pub total_timeout_secs: u64,
}
impl Default for GitHubServiceConfig {
fn default() -> Self {
Self {
base_url: "https://api.github.com".into(),
total_timeout_secs: 120,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ReviewConfig {
pub run_timeout_secs: u64,
}
impl Default for ReviewConfig {
fn default() -> Self {
Self {
run_timeout_secs: 1800,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ThoughtsConfig {
pub add_reference_timeout_secs: u64,
}
impl Default for ThoughtsConfig {
fn default() -> Self {
Self {
add_reference_timeout_secs: 600,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct LoggingConfig {
pub level: String,
pub json: bool,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".into(),
json: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_serializes() {
let config = AgenticConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[subagents]"));
assert!(toml_str.contains("[reasoning]"));
assert!(toml_str.contains("[services.anthropic]"));
assert!(toml_str.contains("[services.exa]"));
assert!(toml_str.contains("[services.linear]"));
assert!(toml_str.contains("[services.github]"));
assert!(toml_str.contains("[review]"));
assert!(toml_str.contains("[thoughts]"));
assert!(toml_str.contains("[orchestrator]"));
assert!(toml_str.contains("[orchestrator.commands]"));
assert!(toml_str.contains("[web_retrieval]"));
assert!(toml_str.contains("[cli_tools]"));
assert!(toml_str.contains("[logging]"));
assert!(!toml_str.contains("[models]"));
}
#[test]
fn test_default_models_use_undated_names() {
let subagents = SubagentsConfig::default();
assert!(!subagents.locator_model.contains("20"));
assert!(!subagents.analyzer_model.contains("20"));
let reasoning = ReasoningConfig::default();
assert!(!reasoning.optimizer_model.contains("20"));
assert!(!reasoning.executor_model.contains("20"));
}
#[test]
fn test_partial_config_deserializes() {
let toml_str = r#"
[subagents]
locator_model = "custom-model"
"#;
let config: AgenticConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.subagents.locator_model, "custom-model");
assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
assert_eq!(config.subagents.runtime_timeout_secs, 3600);
assert_eq!(
config.services.anthropic.base_url,
"https://api.anthropic.com"
);
assert_eq!(
config.services.linear.base_url,
"https://api.linear.app/graphql"
);
assert!(config.orchestrator.commands.allow.is_empty());
assert!(config.orchestrator.commands.deny.is_empty());
}
#[test]
fn test_orchestrator_commands_deserialize() {
let toml_str = r#"
[orchestrator.commands]
allow = ["plan", "research"]
deny = ["commit"]
"#;
let config: AgenticConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
assert_eq!(config.orchestrator.commands.deny, ["commit"]);
}
#[test]
fn test_schema_field_optional() {
let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
let config: AgenticConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
}
#[test]
fn test_web_retrieval_defaults_match_hardcoded() {
let cfg = WebRetrievalConfig::default();
assert_eq!(cfg.request_timeout_secs, 30);
assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); assert_eq!(cfg.default_search_results, 8);
assert_eq!(cfg.max_search_results, 20);
assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
assert_eq!(cfg.summarizer.max_tokens, 300);
assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
}
#[test]
fn test_cli_tools_defaults_match_hardcoded() {
let cfg = CliToolsConfig::default();
assert_eq!(cfg.ls_page_size, 100);
assert_eq!(cfg.grep_default_limit, 200);
assert_eq!(cfg.glob_default_limit, 500);
assert_eq!(cfg.max_depth, 10);
assert_eq!(cfg.pagination_cache_ttl_secs, 300);
assert_eq!(cfg.just_execute_timeout_secs, 1800);
assert_eq!(cfg.just_search_timeout_secs, 30);
assert!(cfg.extra_ignore_patterns.is_empty());
}
#[test]
fn test_orchestrator_defaults_match_hardcoded() {
let cfg = OrchestratorConfig::default();
assert_eq!(cfg.session_deadline_secs, 3600);
assert_eq!(cfg.inactivity_timeout_secs, 300);
assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
assert!(cfg.commands.allow.is_empty());
assert!(cfg.commands.deny.is_empty());
}
#[test]
fn test_services_defaults_match_hardcoded() {
let cfg = ServicesConfig::default();
assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
assert_eq!(cfg.linear.base_url, "https://api.linear.app/graphql");
assert_eq!(cfg.linear.connect_timeout_secs, 10);
assert_eq!(cfg.linear.request_timeout_secs, 60);
assert_eq!(cfg.github.base_url, "https://api.github.com");
assert_eq!(cfg.github.total_timeout_secs, 120);
}
#[test]
fn test_new_timeout_defaults_match_plan() {
let cfg = AgenticConfig::default();
assert_eq!(cfg.subagents.runtime_timeout_secs, 3600);
assert_eq!(cfg.review.run_timeout_secs, 1800);
assert_eq!(cfg.thoughts.add_reference_timeout_secs, 600);
}
}