1use crate::mcp_config::McpServerConfig;
6use crate::{
7 budget::BudgetLimits,
8 hooks::{HookCapability, HookExecutionMode, HookFailurePolicy, HookId, HookPoint},
9 retry::RetryPolicy,
10 types::{OutputSchema, SecurityMode},
11};
12use meerkat_models::ModelTier;
13use schemars::JsonSchema;
14use serde::de::Deserializer;
15use serde::{Deserialize, Serialize};
16use serde_json::value::RawValue;
17use serde_json::{Map, Value};
18use std::collections::{BTreeMap, HashMap};
19use std::path::PathBuf;
20use std::sync::OnceLock;
21use std::time::Duration;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub agent: AgentConfig,
28 pub provider: ProviderConfig,
29 pub storage: StorageConfig,
30 pub budget: BudgetConfig,
31 pub retry: RetryConfig,
32 pub tools: ToolsConfig,
33 pub models: ModelDefaults,
35 pub max_tokens: u32,
36 pub shell: ShellDefaults,
37 pub store: StoreConfig,
38 pub providers: ProviderSettings,
39 pub comms: CommsRuntimeConfig,
40 pub compaction: CompactionRuntimeConfig,
41 pub limits: LimitsConfig,
42 pub rest: RestServerConfig,
43 pub hooks: HooksConfig,
44 pub skills: crate::skills_config::SkillsConfig,
45 pub self_hosted: SelfHostedConfig,
46 pub provider_tools: ProviderToolsConfig,
47}
48
49impl Default for Config {
50 fn default() -> Self {
51 let defaults = template_defaults();
52 let agent = AgentConfig::default();
53 let max_tokens = defaults
54 .max_tokens
55 .filter(|value| *value > 0)
56 .unwrap_or(agent.max_tokens_per_turn);
57 Self {
58 agent,
59 provider: ProviderConfig::default(),
60 storage: StorageConfig::default(),
61 budget: BudgetConfig::default(),
62 retry: RetryConfig::default(),
63 tools: ToolsConfig::default(),
64 models: ModelDefaults::default(),
65 max_tokens,
66 shell: ShellDefaults::default(),
67 store: StoreConfig::default(),
68 providers: ProviderSettings::default(),
69 comms: CommsRuntimeConfig::default(),
70 compaction: CompactionRuntimeConfig::default(),
71 limits: LimitsConfig::default(),
72 rest: RestServerConfig::default(),
73 hooks: HooksConfig::default(),
74 skills: crate::skills_config::SkillsConfig::default(),
75 self_hosted: SelfHostedConfig::default(),
76 provider_tools: ProviderToolsConfig::default(),
77 }
78 }
79}
80
81impl Config {
82 pub fn model_registry(&self) -> Result<crate::ModelRegistry, ConfigError> {
84 crate::ModelRegistry::from_config(self)
85 }
86
87 pub fn template_toml() -> &'static str {
89 CONFIG_TEMPLATE_TOML
90 }
91
92 pub fn template() -> Result<Self, ConfigError> {
94 toml::from_str(CONFIG_TEMPLATE_TOML).map_err(ConfigError::Parse)
95 }
96}
97
98#[cfg(not(target_arch = "wasm32"))]
100impl Config {
101 pub async fn load() -> Result<Self, ConfigError> {
105 let cwd = std::env::current_dir()?;
106 let home = dirs::home_dir();
107 Self::load_from_with_env(&cwd, home.as_deref(), |key| std::env::var(key).ok()).await
108 }
109
110 #[doc(hidden)]
116 pub async fn load_from_with_env<F>(
117 start_dir: &std::path::Path,
118 home_dir: Option<&std::path::Path>,
119 env: F,
120 ) -> Result<Self, ConfigError>
121 where
122 F: FnMut(&str) -> Option<String>,
123 {
124 let mut config = Self::default();
125
126 if let Some(path) = Self::find_project_config_from(start_dir).await {
128 config.merge_file(&path).await?;
129 } else if let Some(path) = home_dir.map(|home| home.join(".rkat/config.toml"))
130 && tokio::fs::try_exists(&path).await.unwrap_or(false)
131 {
132 config.merge_file(&path).await?;
133 }
134
135 config.apply_env_overrides_from(env)?;
137
138 Ok(config)
139 }
140
141 #[doc(hidden)]
143 pub async fn load_from(
144 start_dir: &std::path::Path,
145 home_dir: Option<&std::path::Path>,
146 ) -> Result<Self, ConfigError> {
147 Self::load_from_with_env(start_dir, home_dir, |key| std::env::var(key).ok()).await
148 }
149
150 pub async fn load_layered_hooks() -> Result<HooksConfig, ConfigError> {
155 let cwd = std::env::current_dir()?;
156 let home = dirs::home_dir();
157 Self::load_layered_hooks_from(&cwd, home.as_deref()).await
158 }
159
160 pub async fn load_layered_hooks_from(
162 start_dir: &std::path::Path,
163 home_dir: Option<&std::path::Path>,
164 ) -> Result<HooksConfig, ConfigError> {
165 let mut hooks = HooksConfig::default();
166
167 if let Some(global_path) = home_dir.map(|home| home.join(".rkat/config.toml"))
168 && tokio::fs::try_exists(&global_path).await.unwrap_or(false)
169 {
170 let content = tokio::fs::read_to_string(&global_path).await?;
171 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
172 hooks.append_entries_from(&cfg.hooks);
173 }
174
175 if let Some(project_path) = Self::find_project_config_from(start_dir).await
176 && tokio::fs::try_exists(&project_path).await.unwrap_or(false)
177 {
178 let content = tokio::fs::read_to_string(&project_path).await?;
179 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
180 hooks.append_entries_from(&cfg.hooks);
181 }
182
183 Ok(hooks)
184 }
185}
186
187impl Config {
188 pub fn budget_limits(&self) -> BudgetLimits {
190 self.limits.to_budget_limits()
191 }
192
193 #[cfg(not(target_arch = "wasm32"))]
195 pub fn global_config_path() -> Option<PathBuf> {
196 dirs::home_dir().map(|h| h.join(".rkat/config.toml"))
197 }
198
199 #[cfg(not(target_arch = "wasm32"))]
205 async fn find_project_config_from(start_dir: &std::path::Path) -> Option<PathBuf> {
206 let mut current = start_dir.to_path_buf();
207 loop {
208 let marker_dir = current.join(".rkat");
209 let config_path = marker_dir.join("config.toml");
210
211 let config_exists = tokio::fs::try_exists(&config_path).await.unwrap_or(false);
213 if config_exists {
214 return Some(config_path);
215 }
216
217 if !current.pop() {
218 return None;
219 }
220 }
221 }
222
223 #[cfg(not(target_arch = "wasm32"))]
225 pub async fn merge_file(&mut self, path: &PathBuf) -> Result<(), ConfigError> {
226 let content = tokio::fs::read_to_string(path).await?;
227 self.merge_toml_str(&content)
228 }
229
230 pub fn merge_toml_str(&mut self, content: &str) -> Result<(), ConfigError> {
232 let file_config: Config = toml::from_str(content).map_err(ConfigError::Parse)?;
233 let tools_layer = file_config.tools.clone();
234 let retry_layer = file_config.retry.clone();
235 let self_hosted_layer = file_config.self_hosted.clone();
236 let provider_tools_layer = file_config.provider_tools.clone();
237 self.merge(file_config);
239 let parsed: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
240 self.merge_tools_from_toml_presence(&parsed, &tools_layer);
241 self.merge_retry_from_toml_presence(&parsed, &retry_layer);
242 self.merge_self_hosted_from_toml_presence(&parsed, &self_hosted_layer);
243 self.merge_provider_tools_from_toml_presence(&parsed, &provider_tools_layer);
244 Ok(())
245 }
246
247 fn merge(&mut self, other: Config) {
257 if other.agent.system_prompt.is_some() {
259 self.agent.system_prompt = other.agent.system_prompt;
260 }
261 if other.agent.tool_instructions.is_some() {
262 self.agent.tool_instructions = other.agent.tool_instructions;
263 }
264 if other.agent.model != AgentConfig::default().model {
265 self.agent.model = other.agent.model;
266 }
267 if other.agent.max_tokens_per_turn != AgentConfig::default().max_tokens_per_turn {
268 self.agent.max_tokens_per_turn = other.agent.max_tokens_per_turn;
269 }
270 if other.agent.extraction_prompt.is_some() {
271 self.agent.extraction_prompt = other.agent.extraction_prompt;
272 }
273
274 self.provider = other.provider;
276
277 if other.storage.directory.is_some() {
279 self.storage.directory = other.storage.directory;
280 }
281
282 if other.budget.max_tokens.is_some() {
284 self.budget.max_tokens = other.budget.max_tokens;
285 }
286 if other.budget.max_duration.is_some() {
287 self.budget.max_duration = other.budget.max_duration;
288 }
289 if other.budget.max_tool_calls.is_some() {
290 self.budget.max_tool_calls = other.budget.max_tool_calls;
291 }
292 self.merge_retry(&other.retry);
293 self.merge_tools(&other.tools);
294
295 if other.models != ModelDefaults::default() {
297 self.models = other.models;
298 }
299 if other.max_tokens != Config::default().max_tokens {
300 self.max_tokens = other.max_tokens;
301 }
302 if other.shell != ShellDefaults::default() {
303 self.shell = other.shell;
304 }
305 if other.store != StoreConfig::default() {
306 self.store = other.store;
307 }
308 if other.providers != ProviderSettings::default() {
309 self.providers = other.providers;
310 }
311 if other.comms != CommsRuntimeConfig::default() {
312 self.comms = other.comms;
313 }
314 if other.compaction != CompactionRuntimeConfig::default() {
315 self.compaction = other.compaction;
316 }
317 if other.limits != LimitsConfig::default() {
318 self.limits = other.limits;
319 }
320 if other.rest != RestServerConfig::default() {
321 self.rest = other.rest;
322 }
323 if other.hooks != HooksConfig::default() {
324 let default_hooks = HooksConfig::default();
325 if other.hooks.default_timeout_ms != default_hooks.default_timeout_ms {
326 self.hooks.default_timeout_ms = other.hooks.default_timeout_ms;
327 }
328 if other.hooks.payload_max_bytes != default_hooks.payload_max_bytes {
329 self.hooks.payload_max_bytes = other.hooks.payload_max_bytes;
330 }
331 if other.hooks.background_max_concurrency != default_hooks.background_max_concurrency {
332 self.hooks.background_max_concurrency = other.hooks.background_max_concurrency;
333 }
334 self.hooks.entries.extend(other.hooks.entries);
335 }
336 }
337
338 fn merge_retry(&mut self, other: &RetryConfig) {
339 let defaults = RetryConfig::default();
340 if other.max_retries != defaults.max_retries {
341 self.retry.max_retries = other.max_retries;
342 }
343 if other.initial_delay != defaults.initial_delay {
344 self.retry.initial_delay = other.initial_delay;
345 }
346 if other.max_delay != defaults.max_delay {
347 self.retry.max_delay = other.max_delay;
348 }
349 if other.multiplier != defaults.multiplier {
350 self.retry.multiplier = other.multiplier;
351 }
352 if other.call_timeout_override != defaults.call_timeout_override {
353 self.retry.call_timeout_override = other.call_timeout_override.clone();
354 }
355 }
356
357 fn merge_tools(&mut self, other: &ToolsConfig) {
358 let defaults = ToolsConfig::default();
359 if !other.mcp_servers.is_empty() {
360 self.tools.mcp_servers.clone_from(&other.mcp_servers);
361 }
362 if other.default_timeout != defaults.default_timeout {
363 self.tools.default_timeout = other.default_timeout;
364 }
365 if other.tool_timeouts != defaults.tool_timeouts {
366 self.tools.tool_timeouts.clone_from(&other.tool_timeouts);
367 }
368 if other.max_concurrent != defaults.max_concurrent {
369 self.tools.max_concurrent = other.max_concurrent;
370 }
371 if other.builtins_enabled != defaults.builtins_enabled {
372 self.tools.builtins_enabled = other.builtins_enabled;
373 }
374 if other.shell_enabled != defaults.shell_enabled {
375 self.tools.shell_enabled = other.shell_enabled;
376 }
377 if other.comms_enabled != defaults.comms_enabled {
378 self.tools.comms_enabled = other.comms_enabled;
379 }
380 if other.mob_enabled != defaults.mob_enabled {
381 self.tools.mob_enabled = other.mob_enabled;
382 }
383 }
384
385 fn merge_tools_from_toml_presence(&mut self, parsed: &toml::Value, layer: &ToolsConfig) {
386 let Some(tools) = parsed.get("tools").and_then(toml::Value::as_table) else {
387 return;
388 };
389 if tools.contains_key("mcp_servers") {
390 self.tools.mcp_servers.clone_from(&layer.mcp_servers);
391 }
392 if tools.contains_key("default_timeout") {
393 self.tools.default_timeout = layer.default_timeout;
394 }
395 if tools.contains_key("tool_timeouts") {
396 self.tools.tool_timeouts.clone_from(&layer.tool_timeouts);
397 }
398 if tools.contains_key("max_concurrent") {
399 self.tools.max_concurrent = layer.max_concurrent;
400 }
401 if tools.contains_key("builtins_enabled") {
402 self.tools.builtins_enabled = layer.builtins_enabled;
403 }
404 if tools.contains_key("shell_enabled") {
405 self.tools.shell_enabled = layer.shell_enabled;
406 }
407 if tools.contains_key("comms_enabled") {
408 self.tools.comms_enabled = layer.comms_enabled;
409 }
410 if tools.contains_key("mob_enabled") {
411 self.tools.mob_enabled = layer.mob_enabled;
412 }
413 }
414
415 fn merge_retry_from_toml_presence(&mut self, parsed: &toml::Value, layer: &RetryConfig) {
416 let Some(retry) = parsed.get("retry").and_then(toml::Value::as_table) else {
417 return;
418 };
419 if retry.contains_key("max_retries") {
420 self.retry.max_retries = layer.max_retries;
421 }
422 if retry.contains_key("initial_delay") {
423 self.retry.initial_delay = layer.initial_delay;
424 }
425 if retry.contains_key("max_delay") {
426 self.retry.max_delay = layer.max_delay;
427 }
428 if retry.contains_key("multiplier") {
429 self.retry.multiplier = layer.multiplier;
430 }
431 if retry.contains_key("call_timeout") {
432 self.retry.call_timeout_override = layer.call_timeout_override.clone();
433 }
434 }
435
436 fn merge_provider_tools_from_toml_presence(
437 &mut self,
438 parsed: &toml::Value,
439 layer: &ProviderToolsConfig,
440 ) {
441 let Some(pt) = parsed.get("provider_tools").and_then(toml::Value::as_table) else {
442 return;
443 };
444 if let Some(anthropic) = pt.get("anthropic").and_then(toml::Value::as_table)
445 && anthropic.contains_key("web_search")
446 {
447 self.provider_tools.anthropic.web_search = layer.anthropic.web_search;
448 }
449 if let Some(openai) = pt.get("openai").and_then(toml::Value::as_table)
450 && openai.contains_key("web_search")
451 {
452 self.provider_tools.openai.web_search = layer.openai.web_search;
453 }
454 if let Some(gemini) = pt.get("gemini").and_then(toml::Value::as_table)
455 && gemini.contains_key("google_search")
456 {
457 self.provider_tools.gemini.google_search = layer.gemini.google_search;
458 }
459 }
460
461 fn merge_self_hosted_from_toml_presence(
462 &mut self,
463 parsed: &toml::Value,
464 layer: &SelfHostedConfig,
465 ) {
466 let Some(self_hosted) = parsed.get("self_hosted").and_then(toml::Value::as_table) else {
467 return;
468 };
469
470 if let Some(servers) = self_hosted.get("servers").and_then(toml::Value::as_table) {
471 if servers.is_empty() {
472 self.self_hosted.servers.clear();
473 self.self_hosted.models.clear();
474 } else {
475 let mut merged_servers = self.self_hosted.servers.clone();
476 for (server_id, server_value) in servers {
477 let Some(server_table) = server_value.as_table() else {
478 continue;
479 };
480 let mut merged = self
481 .self_hosted
482 .servers
483 .get(server_id)
484 .cloned()
485 .unwrap_or_default();
486 let Some(server_layer) = layer.servers.get(server_id) else {
487 continue;
488 };
489 if server_table.contains_key("transport") {
490 merged.transport = server_layer.transport;
491 }
492 if server_table.contains_key("base_url") {
493 merged.base_url = server_layer.base_url.clone();
494 }
495 if server_table.contains_key("api_style") {
496 merged.api_style = server_layer.api_style;
497 }
498 if server_table.contains_key("bearer_token") {
499 merged.bearer_token = server_layer.bearer_token.clone();
500 }
501 if server_table.contains_key("bearer_token_env") {
502 merged.bearer_token_env = server_layer.bearer_token_env.clone();
503 }
504 merged_servers.insert(server_id.clone(), merged);
505 }
506 self.self_hosted.servers = merged_servers;
507 }
508 }
509
510 if let Some(models) = self_hosted.get("models").and_then(toml::Value::as_table) {
511 if models.is_empty() {
512 self.self_hosted.models.clear();
513 } else {
514 let mut merged_models = self.self_hosted.models.clone();
515 for (model_id, model_value) in models {
516 let Some(model_table) = model_value.as_table() else {
517 continue;
518 };
519 let mut merged = self
520 .self_hosted
521 .models
522 .get(model_id)
523 .cloned()
524 .unwrap_or_default();
525 let Some(model_layer) = layer.models.get(model_id) else {
526 continue;
527 };
528 if model_table.contains_key("server") {
529 merged.server = model_layer.server.clone();
530 }
531 if model_table.contains_key("remote_model") {
532 merged.remote_model = model_layer.remote_model.clone();
533 }
534 if model_table.contains_key("display_name") {
535 merged.display_name = model_layer.display_name.clone();
536 }
537 if model_table.contains_key("family") {
538 merged.family = model_layer.family.clone();
539 }
540 if model_table.contains_key("tier") {
541 merged.tier = model_layer.tier;
542 }
543 if model_table.contains_key("context_window") {
544 merged.context_window = model_layer.context_window;
545 }
546 if model_table.contains_key("max_output_tokens") {
547 merged.max_output_tokens = model_layer.max_output_tokens;
548 }
549 if model_table.contains_key("vision") {
550 merged.vision = model_layer.vision;
551 }
552 if model_table.contains_key("image_tool_results") {
553 merged.image_tool_results = model_layer.image_tool_results;
554 }
555 if model_table.contains_key("inline_video") {
556 merged.inline_video = model_layer.inline_video;
557 }
558 if model_table.contains_key("supports_temperature") {
559 merged.supports_temperature = model_layer.supports_temperature;
560 }
561 if model_table.contains_key("supports_thinking") {
562 merged.supports_thinking = model_layer.supports_thinking;
563 }
564 if model_table.contains_key("supports_reasoning") {
565 merged.supports_reasoning = model_layer.supports_reasoning;
566 }
567 if model_table.contains_key("call_timeout_secs") {
568 merged.call_timeout_secs = model_layer.call_timeout_secs;
569 }
570 merged_models.insert(model_id.clone(), merged);
571 }
572 self.self_hosted.models = merged_models;
573 }
574 }
575 }
576
577 pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
579 self.apply_env_overrides_from(|key| std::env::var(key).ok())
580 }
581
582 #[doc(hidden)]
587 pub fn apply_env_overrides_from<F>(&mut self, mut env: F) -> Result<(), ConfigError>
588 where
589 F: FnMut(&str) -> Option<String>,
590 {
591 match &mut self.provider {
593 ProviderConfig::Anthropic { api_key, .. } => {
594 if api_key.is_none() {
595 let key = env("RKAT_ANTHROPIC_API_KEY").or_else(|| env("ANTHROPIC_API_KEY"));
596 if let Some(key) = key {
597 *api_key = Some(key);
598 }
599 }
600 }
601 ProviderConfig::OpenAI { api_key, .. } => {
602 if api_key.is_none() {
603 let key = env("RKAT_OPENAI_API_KEY").or_else(|| env("OPENAI_API_KEY"));
604 if let Some(key) = key {
605 *api_key = Some(key);
606 }
607 }
608 }
609 ProviderConfig::Gemini { api_key } => {
610 if api_key.is_none() {
611 let key = env("RKAT_GEMINI_API_KEY")
613 .or_else(|| env("GEMINI_API_KEY"))
614 .or_else(|| env("GOOGLE_API_KEY"));
615 if let Some(key) = key {
616 *api_key = Some(key);
617 }
618 }
619 }
620 }
621
622 Ok(())
623 }
624
625 #[cfg(not(target_arch = "wasm32"))]
630 pub fn apply_cli_overrides(&mut self, cli: CliOverrides) {
631 if let Some(model) = cli.model {
632 self.agent.model = model;
633 }
634 if let Some(tokens) = cli.max_tokens {
635 self.budget.max_tokens = Some(tokens);
636 }
637 if let Some(duration) = cli.max_duration {
638 self.budget.max_duration = Some(duration);
639 }
640 if let Some(calls) = cli.max_tool_calls {
641 self.budget.max_tool_calls = Some(calls);
642 }
643 if let Some(delta) = cli.override_config {
645 let mut value = serde_json::to_value(&self).unwrap_or_default();
646 crate::config_store::merge_patch(&mut value, delta.0);
647 if let Ok(updated) = serde_json::from_value(value) {
648 *self = updated;
649 }
650 }
651 }
652}
653
654impl Config {
655 pub fn validate(&self) -> Result<(), ConfigError> {
662 if self.max_tokens == 0 {
663 return Err(ConfigError::Validation(
664 "max_tokens must be greater than 0".to_string(),
665 ));
666 }
667 if self.agent.max_tokens_per_turn == 0 {
668 return Err(ConfigError::Validation(
669 "agent.max_tokens_per_turn must be greater than 0".to_string(),
670 ));
671 }
672 if self.budget.max_tokens == Some(0) {
673 return Err(ConfigError::Validation(
674 "budget.max_tokens must be greater than 0 when set".to_string(),
675 ));
676 }
677 if self.limits.budget == Some(0) {
678 return Err(ConfigError::Validation(
679 "limits.budget must be greater than 0 when set".to_string(),
680 ));
681 }
682 if self.compaction.auto_compact_threshold == 0 {
683 return Err(ConfigError::Validation(
684 "compaction.auto_compact_threshold must be greater than 0".to_string(),
685 ));
686 }
687 if self.compaction.recent_turn_budget == 0 {
688 return Err(ConfigError::Validation(
689 "compaction.recent_turn_budget must be greater than 0".to_string(),
690 ));
691 }
692 if self.compaction.max_summary_tokens == 0 {
693 return Err(ConfigError::Validation(
694 "compaction.max_summary_tokens must be greater than 0".to_string(),
695 ));
696 }
697 if self.compaction.min_turns_between_compactions == 0 {
698 return Err(ConfigError::Validation(
699 "compaction.min_turns_between_compactions must be greater than 0".to_string(),
700 ));
701 }
702
703 if let Some(base_urls) = &self.providers.base_urls {
704 let maybe_conflict = match &self.provider {
705 ProviderConfig::Anthropic {
706 base_url: Some(url),
707 ..
708 } => base_urls.get("anthropic").filter(|mapped| *mapped != url),
709 ProviderConfig::OpenAI {
710 base_url: Some(url),
711 ..
712 } => base_urls.get("openai").filter(|mapped| *mapped != url),
713 _ => None,
714 };
715 if maybe_conflict.is_some() {
716 return Err(ConfigError::Validation(
717 "provider base_url conflicts with providers.base_urls entry".to_string(),
718 ));
719 }
720 }
721
722 if let Some(api_keys) = &self.providers.api_keys {
723 let maybe_conflict = match &self.provider {
724 ProviderConfig::Anthropic {
725 api_key: Some(key), ..
726 } => api_keys.get("anthropic").filter(|mapped| *mapped != key),
727 ProviderConfig::OpenAI {
728 api_key: Some(key), ..
729 } => api_keys.get("openai").filter(|mapped| *mapped != key),
730 ProviderConfig::Gemini { api_key: Some(key) } => {
731 api_keys.get("gemini").filter(|mapped| *mapped != key)
732 }
733 _ => None,
734 };
735 if maybe_conflict.is_some() {
736 return Err(ConfigError::Validation(
737 "provider api_key conflicts with providers.api_keys entry".to_string(),
738 ));
739 }
740 }
741
742 crate::model_registry::ModelRegistry::from_config(self)?;
743
744 Ok(())
745 }
746}
747
748#[derive(Debug, Clone, Default)]
750pub struct CliOverrides {
751 pub model: Option<String>,
752 pub max_tokens: Option<u64>,
753 pub max_duration: Option<Duration>,
754 pub max_tool_calls: Option<usize>,
755 pub override_config: Option<ConfigDelta>,
757}
758
759fn default_structured_output_retries() -> u32 {
760 2
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(default)]
766pub struct AgentConfig {
767 pub system_prompt: Option<String>,
769 pub system_prompt_file: Option<PathBuf>,
771 pub tool_instructions: Option<String>,
773 pub model: String,
775 pub max_tokens_per_turn: u32,
777 pub temperature: Option<f32>,
779 pub budget_warning_threshold: f32,
781 pub max_turns: Option<u32>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub provider_params: Option<serde_json::Value>,
790 #[serde(skip)]
801 pub provider_tool_defaults: Option<serde_json::Value>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub output_schema: Option<OutputSchema>,
809 #[serde(default = "default_structured_output_retries")]
811 pub structured_output_retries: u32,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub extraction_prompt: Option<String>,
819}
820
821impl Default for AgentConfig {
822 fn default() -> Self {
823 let defaults = template_defaults();
824 let agent = defaults.agent.as_ref();
825 Self {
826 system_prompt: None,
827 system_prompt_file: None,
828 tool_instructions: None,
829 model: agent.and_then(|cfg| cfg.model.clone()).unwrap_or_default(),
830 max_tokens_per_turn: agent
831 .and_then(|cfg| cfg.max_tokens_per_turn)
832 .unwrap_or_default(),
833 temperature: None,
834 budget_warning_threshold: agent
835 .and_then(|cfg| cfg.budget_warning_threshold)
836 .unwrap_or_default(),
837 max_turns: None,
838 provider_params: None,
839 provider_tool_defaults: None,
840 output_schema: None,
841 structured_output_retries: default_structured_output_retries(),
842 extraction_prompt: None,
843 }
844 }
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
849#[serde(default)]
850pub struct ModelDefaults {
851 pub anthropic: String,
852 pub openai: String,
853 pub gemini: String,
854}
855
856impl Default for ModelDefaults {
857 fn default() -> Self {
858 Self {
859 anthropic: meerkat_models::default_model("anthropic")
860 .unwrap_or_default()
861 .to_string(),
862 openai: meerkat_models::default_model("openai")
863 .unwrap_or_default()
864 .to_string(),
865 gemini: meerkat_models::default_model("gemini")
866 .unwrap_or_default()
867 .to_string(),
868 }
869 }
870}
871
872pub const DEFAULT_SHELL_PROGRAM: &str = "nu";
874pub const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 30;
876pub const DEFAULT_SHELL_SECURITY_MODE: SecurityMode = SecurityMode::Unrestricted;
878
879#[derive(Debug, Clone, Serialize, PartialEq)]
881#[serde(default)]
882pub struct ShellDefaults {
883 pub program: String,
884 pub timeout_secs: u64,
885 pub security_mode: SecurityMode,
887 pub security_patterns: Vec<String>,
889}
890
891#[derive(Debug, Deserialize, Default)]
892#[serde(default)]
893struct ShellDefaultsSeed {
894 program: Option<String>,
895 timeout_secs: Option<u64>,
896 security_mode: Option<SecurityMode>,
897 security_patterns: Option<Vec<String>>,
898 #[serde(alias = "allowlist")]
899 allowlist: Option<Vec<String>>,
900}
901
902impl<'de> Deserialize<'de> for ShellDefaults {
903 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
904 where
905 D: Deserializer<'de>,
906 {
907 let seed = ShellDefaultsSeed::deserialize(deserializer)?;
908 let mut defaults = ShellDefaults::default();
909
910 if let Some(program) = seed.program {
911 defaults.program = program;
912 }
913 if let Some(timeout_secs) = seed.timeout_secs {
914 defaults.timeout_secs = timeout_secs;
915 }
916 if let Some(security_mode) = seed.security_mode {
917 defaults.security_mode = security_mode;
918 }
919 if let Some(security_patterns) = seed.security_patterns.or(seed.allowlist.clone()) {
920 defaults.security_patterns = security_patterns;
921 }
922
923 if seed.security_mode.is_none() && seed.allowlist.is_some() {
924 defaults.security_mode = SecurityMode::AllowList;
925 }
926
927 Ok(defaults)
928 }
929}
930
931impl Default for ShellDefaults {
932 fn default() -> Self {
933 let defaults = template_defaults();
934 let shell = defaults.shell.as_ref();
935 Self {
936 program: shell
937 .and_then(|cfg| cfg.program.clone())
938 .unwrap_or_else(|| DEFAULT_SHELL_PROGRAM.to_string()),
939 timeout_secs: shell
940 .and_then(|cfg| cfg.timeout_secs)
941 .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS),
942 security_mode: shell
943 .and_then(|cfg| cfg.security_mode)
944 .unwrap_or(DEFAULT_SHELL_SECURITY_MODE),
945 security_patterns: shell
946 .and_then(|cfg| cfg.security_patterns.clone())
947 .unwrap_or_default(),
948 }
949 }
950}
951
952const CONFIG_TEMPLATE_TOML: &str = include_str!("config_template.toml");
953
954#[derive(Debug, Deserialize)]
955struct TemplateAgentDefaults {
956 model: Option<String>,
957 max_tokens_per_turn: Option<u32>,
958 budget_warning_threshold: Option<f32>,
959}
960
961#[derive(Debug, Deserialize)]
962struct TemplateShellDefaults {
963 program: Option<String>,
964 timeout_secs: Option<u64>,
965 security_mode: Option<SecurityMode>,
966 security_patterns: Option<Vec<String>>,
967}
968
969#[derive(Debug, Deserialize)]
970struct TemplateDefaults {
971 agent: Option<TemplateAgentDefaults>,
972 shell: Option<TemplateShellDefaults>,
973 max_tokens: Option<u32>,
974}
975
976impl TemplateDefaults {
977 fn empty() -> Self {
978 Self {
979 agent: None,
980 shell: None,
981 max_tokens: None,
982 }
983 }
984}
985
986fn template_defaults() -> &'static TemplateDefaults {
987 static DEFAULTS: OnceLock<TemplateDefaults> = OnceLock::new();
988 DEFAULTS.get_or_init(|| {
989 toml::from_str(CONFIG_TEMPLATE_TOML).unwrap_or_else(|e| {
990 tracing::error!("Invalid config template defaults: {}", e);
993 TemplateDefaults::empty()
994 })
995 })
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1000#[serde(default)]
1001pub struct StoreConfig {
1002 pub sessions_path: Option<PathBuf>,
1003 pub tasks_path: Option<PathBuf>,
1004 pub database_dir: Option<PathBuf>,
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1010#[serde(default)]
1011pub struct ProviderSettings {
1012 pub base_urls: Option<HashMap<String, String>>,
1013 pub api_keys: Option<HashMap<String, String>>,
1014}
1015
1016#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1017#[serde(rename_all = "snake_case")]
1018pub enum SelfHostedTransport {
1019 #[default]
1020 #[serde(alias = "openai_compatible")]
1021 OpenAiCompatible,
1022}
1023
1024#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1025#[serde(rename_all = "snake_case")]
1026pub enum SelfHostedApiStyle {
1027 Responses,
1028 #[default]
1029 ChatCompletions,
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1033#[serde(default)]
1034pub struct SelfHostedServerConfig {
1035 pub transport: SelfHostedTransport,
1036 pub base_url: String,
1037 pub api_style: SelfHostedApiStyle,
1038 #[serde(default, skip_serializing)]
1039 pub bearer_token: Option<String>,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub bearer_token_env: Option<String>,
1042}
1043
1044impl Default for SelfHostedServerConfig {
1045 fn default() -> Self {
1046 Self {
1047 transport: SelfHostedTransport::OpenAiCompatible,
1048 base_url: String::new(),
1049 api_style: SelfHostedApiStyle::ChatCompletions,
1050 bearer_token: None,
1051 bearer_token_env: None,
1052 }
1053 }
1054}
1055
1056impl SelfHostedServerConfig {
1057 pub fn resolve_bearer_token(&self) -> Option<String> {
1058 self.bearer_token.clone().or_else(|| {
1059 self.bearer_token_env
1060 .as_deref()
1061 .and_then(|env_key| std::env::var(env_key).ok())
1062 })
1063 }
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1067#[serde(default)]
1068pub struct SelfHostedModelConfig {
1069 pub server: String,
1070 pub remote_model: String,
1071 pub display_name: String,
1072 pub family: String,
1073 pub tier: ModelTier,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub context_window: Option<u32>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub max_output_tokens: Option<u32>,
1078 pub vision: bool,
1079 pub image_tool_results: bool,
1080 pub inline_video: bool,
1081 pub supports_temperature: bool,
1082 pub supports_thinking: bool,
1083 pub supports_reasoning: bool,
1084 #[serde(default)]
1086 pub supports_web_search: bool,
1087 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub call_timeout_secs: Option<u64>,
1089}
1090
1091impl Default for SelfHostedModelConfig {
1092 fn default() -> Self {
1093 Self {
1094 server: String::new(),
1095 remote_model: String::new(),
1096 display_name: String::new(),
1097 family: String::new(),
1098 tier: ModelTier::Supported,
1099 context_window: None,
1100 max_output_tokens: None,
1101 vision: false,
1102 image_tool_results: false,
1103 inline_video: false,
1104 supports_temperature: true,
1105 supports_thinking: false,
1106 supports_reasoning: false,
1107 supports_web_search: false,
1108 call_timeout_secs: None,
1109 }
1110 }
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1114#[serde(default)]
1115pub struct SelfHostedConfig {
1116 pub servers: BTreeMap<String, SelfHostedServerConfig>,
1117 pub models: BTreeMap<String, SelfHostedModelConfig>,
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1131#[serde(default)]
1132pub struct ProviderToolsConfig {
1133 pub anthropic: AnthropicProviderToolsConfig,
1134 pub openai: OpenAiProviderToolsConfig,
1135 pub gemini: GeminiProviderToolsConfig,
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1140#[serde(default)]
1141pub struct AnthropicProviderToolsConfig {
1142 pub web_search: bool,
1144}
1145
1146impl Default for AnthropicProviderToolsConfig {
1147 fn default() -> Self {
1148 Self { web_search: true }
1149 }
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1154#[serde(default)]
1155pub struct OpenAiProviderToolsConfig {
1156 pub web_search: bool,
1158}
1159
1160impl Default for OpenAiProviderToolsConfig {
1161 fn default() -> Self {
1162 Self { web_search: true }
1163 }
1164}
1165
1166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1168#[serde(default)]
1169pub struct GeminiProviderToolsConfig {
1170 pub google_search: bool,
1172}
1173
1174impl Default for GeminiProviderToolsConfig {
1175 fn default() -> Self {
1176 Self {
1177 google_search: true,
1178 }
1179 }
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1184#[serde(default)]
1185pub struct LimitsConfig {
1186 pub budget: Option<u64>,
1187 #[serde(with = "optional_duration_serde")]
1188 pub max_duration: Option<Duration>,
1189}
1190
1191impl LimitsConfig {
1192 pub fn to_budget_limits(&self) -> BudgetLimits {
1193 BudgetLimits {
1194 max_tokens: self.budget,
1195 max_duration: self.max_duration,
1196 max_tool_calls: None,
1197 }
1198 }
1199}
1200
1201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1203#[serde(default)]
1204pub struct RestServerConfig {
1205 pub host: String,
1206 pub port: u16,
1207}
1208
1209impl Default for RestServerConfig {
1210 fn default() -> Self {
1211 Self {
1212 host: "127.0.0.1".to_string(),
1213 port: 8080,
1214 }
1215 }
1216}
1217
1218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
1228#[serde(rename_all = "snake_case")]
1229pub enum CommsAuthMode {
1230 #[default]
1231 #[serde(rename = "none")]
1232 Open,
1233 Ed25519,
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1241#[serde(rename_all = "snake_case")]
1242pub enum PlainEventSource {
1243 Tcp,
1244 Uds,
1245 Stdin,
1246 Webhook,
1247 Rpc,
1248}
1249
1250impl std::fmt::Display for PlainEventSource {
1251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1252 match self {
1253 Self::Tcp => write!(f, "tcp"),
1254 Self::Uds => write!(f, "uds"),
1255 Self::Stdin => write!(f, "stdin"),
1256 Self::Webhook => write!(f, "webhook"),
1257 Self::Rpc => write!(f, "rpc"),
1258 }
1259 }
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1264#[serde(default)]
1265pub struct CommsRuntimeConfig {
1266 pub mode: CommsRuntimeMode,
1267 pub address: Option<String>,
1269 pub auth: CommsAuthMode,
1270 pub require_peer_auth: bool,
1276 pub event_address: Option<String>,
1279}
1280
1281impl Default for CommsRuntimeConfig {
1282 fn default() -> Self {
1283 Self {
1284 mode: CommsRuntimeMode::Inproc,
1285 address: None,
1286 auth: CommsAuthMode::default(),
1287 require_peer_auth: true,
1288 event_address: None,
1289 }
1290 }
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1298#[serde(default)]
1299pub struct CompactionRuntimeConfig {
1300 pub auto_compact_threshold: u64,
1302 pub recent_turn_budget: usize,
1304 pub max_summary_tokens: u32,
1306 pub min_turns_between_compactions: u32,
1308}
1309
1310impl Default for CompactionRuntimeConfig {
1311 fn default() -> Self {
1312 Self {
1313 auto_compact_threshold: 100_000,
1314 recent_turn_budget: 4,
1315 max_summary_tokens: 4096,
1316 min_turns_between_compactions: 3,
1317 }
1318 }
1319}
1320
1321impl From<CompactionRuntimeConfig> for crate::CompactionConfig {
1322 fn from(value: CompactionRuntimeConfig) -> Self {
1323 Self {
1324 auto_compact_threshold: value.auto_compact_threshold,
1325 recent_turn_budget: value.recent_turn_budget,
1326 max_summary_tokens: value.max_summary_tokens,
1327 min_turns_between_compactions: value.min_turns_between_compactions,
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
1334#[serde(rename_all = "snake_case")]
1335pub enum CommsRuntimeMode {
1336 #[default]
1337 Inproc,
1338 Tcp,
1339 Uds,
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize)]
1344#[serde(tag = "type", rename_all = "snake_case")]
1345pub enum ProviderConfig {
1346 Anthropic {
1347 api_key: Option<String>,
1348 base_url: Option<String>,
1349 },
1350 #[serde(rename = "openai")]
1351 OpenAI {
1352 api_key: Option<String>,
1353 base_url: Option<String>,
1354 },
1355 Gemini {
1356 api_key: Option<String>,
1357 },
1358}
1359
1360impl Default for ProviderConfig {
1361 fn default() -> Self {
1362 Self::Anthropic {
1363 api_key: None,
1364 base_url: None,
1365 }
1366 }
1367}
1368
1369#[derive(Debug, Clone, Serialize, Deserialize)]
1371#[serde(default)]
1372pub struct StorageConfig {
1373 pub directory: Option<PathBuf>,
1375}
1376
1377impl Default for StorageConfig {
1378 fn default() -> Self {
1379 Self {
1380 directory: data_dir().map(|d| d.join("sessions")),
1381 }
1382 }
1383}
1384
1385#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1387#[serde(default)]
1388pub struct BudgetConfig {
1389 pub max_tokens: Option<u64>,
1391 #[serde(with = "optional_duration_serde")]
1393 pub max_duration: Option<Duration>,
1394 pub max_tool_calls: Option<usize>,
1396}
1397
1398#[derive(Debug, Clone, Default, PartialEq, Eq)]
1410#[non_exhaustive]
1411pub enum CallTimeoutOverride {
1412 #[default]
1414 Inherit,
1415 Disabled,
1417 Value(Duration),
1419}
1420
1421impl Serialize for CallTimeoutOverride {
1422 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1423 match self {
1424 Self::Inherit => serializer.serialize_none(),
1426 Self::Disabled => serializer.serialize_str("disabled"),
1427 Self::Value(d) => {
1428 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1429 serializer.serialize_str(&s)
1430 }
1431 }
1432 }
1433}
1434
1435impl<'de> Deserialize<'de> for CallTimeoutOverride {
1436 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1437 let s = String::deserialize(deserializer)?;
1438 if s == "disabled" {
1439 return Ok(Self::Disabled);
1440 }
1441 let d: Duration = s
1442 .parse::<humantime_serde::re::humantime::Duration>()
1443 .map(|ht| *ht)
1444 .map_err(serde::de::Error::custom)?;
1445 Ok(Self::Value(d))
1446 }
1447}
1448
1449impl CallTimeoutOverride {
1450 pub fn is_inherit(&self) -> bool {
1452 matches!(self, Self::Inherit)
1453 }
1454}
1455
1456#[derive(Debug, Clone, Serialize, Deserialize)]
1458#[serde(default)]
1459pub struct RetryConfig {
1460 pub max_retries: u32,
1462 #[serde(with = "humantime_serde")]
1464 pub initial_delay: Duration,
1465 #[serde(with = "humantime_serde")]
1467 pub max_delay: Duration,
1468 pub multiplier: f64,
1470 #[serde(
1476 default,
1477 rename = "call_timeout",
1478 skip_serializing_if = "CallTimeoutOverride::is_inherit"
1479 )]
1480 pub call_timeout_override: CallTimeoutOverride,
1481}
1482
1483impl Default for RetryConfig {
1484 fn default() -> Self {
1485 let policy = RetryPolicy::default();
1486 Self {
1487 max_retries: policy.max_retries,
1488 initial_delay: policy.initial_delay,
1489 max_delay: policy.max_delay,
1490 multiplier: policy.multiplier,
1491 call_timeout_override: CallTimeoutOverride::default(),
1492 }
1493 }
1494}
1495
1496impl From<RetryConfig> for RetryPolicy {
1497 fn from(config: RetryConfig) -> Self {
1498 let call_timeout = match config.call_timeout_override {
1501 CallTimeoutOverride::Inherit => None,
1502 CallTimeoutOverride::Disabled => None,
1503 CallTimeoutOverride::Value(d) => Some(d),
1504 };
1505 RetryPolicy {
1506 max_retries: config.max_retries,
1507 initial_delay: config.initial_delay,
1508 max_delay: config.max_delay,
1509 multiplier: config.multiplier,
1510 call_timeout,
1511 }
1512 }
1513}
1514
1515#[derive(Debug, Clone, Serialize, Deserialize)]
1517#[serde(default)]
1518pub struct ToolsConfig {
1519 #[serde(default)]
1521 pub mcp_servers: Vec<McpServerConfig>,
1522 #[serde(with = "humantime_serde")]
1524 pub default_timeout: Duration,
1525 #[serde(default)]
1527 pub tool_timeouts: HashMap<String, Duration>,
1528 pub max_concurrent: usize,
1530 pub builtins_enabled: bool,
1532 pub shell_enabled: bool,
1534 pub comms_enabled: bool,
1536 pub mob_enabled: bool,
1538}
1539
1540impl Default for ToolsConfig {
1541 fn default() -> Self {
1542 Self {
1543 mcp_servers: Vec::new(),
1544 default_timeout: Duration::from_secs(600),
1545 tool_timeouts: HashMap::new(),
1546 max_concurrent: 10,
1547 builtins_enabled: false,
1548 shell_enabled: false,
1549 comms_enabled: false,
1550 mob_enabled: false,
1551 }
1552 }
1553}
1554
1555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1557#[serde(default)]
1558pub struct HooksConfig {
1559 pub default_timeout_ms: u64,
1561 pub payload_max_bytes: usize,
1563 pub background_max_concurrency: usize,
1565 #[serde(default)]
1567 pub entries: Vec<HookEntryConfig>,
1568}
1569
1570impl HooksConfig {
1571 pub fn append_entries_from(&mut self, other: &HooksConfig) {
1572 self.entries.extend(other.entries.clone());
1573 }
1574}
1575
1576impl Default for HooksConfig {
1577 fn default() -> Self {
1578 Self {
1579 default_timeout_ms: 5_000,
1580 payload_max_bytes: 128 * 1024,
1581 background_max_concurrency: 32,
1582 entries: Vec::new(),
1583 }
1584 }
1585}
1586
1587#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1589#[serde(default)]
1590pub struct HookRunOverrides {
1591 #[serde(default)]
1593 pub entries: Vec<HookEntryConfig>,
1594 #[serde(default)]
1596 pub disable: Vec<HookId>,
1597}
1598
1599#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1601#[serde(default)]
1602pub struct HookEntryConfig {
1603 pub id: HookId,
1604 pub enabled: bool,
1605 pub point: HookPoint,
1606 pub mode: HookExecutionMode,
1607 pub capability: HookCapability,
1608 pub priority: i32,
1609 #[serde(default, skip_serializing_if = "Option::is_none")]
1610 pub failure_policy: Option<HookFailurePolicy>,
1611 #[serde(default, skip_serializing_if = "Option::is_none")]
1612 pub timeout_ms: Option<u64>,
1613 pub runtime: HookRuntimeConfig,
1614}
1615
1616impl HookEntryConfig {
1617 pub fn effective_failure_policy(&self) -> HookFailurePolicy {
1618 self.failure_policy
1619 .unwrap_or_else(|| crate::hooks::default_failure_policy(self.capability))
1620 }
1621}
1622
1623impl Default for HookEntryConfig {
1624 fn default() -> Self {
1625 Self {
1626 id: HookId::new("hook"),
1627 enabled: true,
1628 point: HookPoint::TurnBoundary,
1629 mode: HookExecutionMode::Foreground,
1630 capability: HookCapability::Observe,
1631 priority: 100,
1632 failure_policy: None,
1633 timeout_ms: None,
1634 runtime: HookRuntimeConfig::new("in_process", Some(serde_json::json!({"name":"noop"})))
1635 .unwrap_or_else(|_| HookRuntimeConfig {
1636 kind: "in_process".to_string(),
1637 config: None,
1638 }),
1639 }
1640 }
1641}
1642
1643#[derive(Debug, Clone)]
1647pub struct HookRuntimeConfig {
1648 pub kind: String,
1649 #[allow(clippy::box_collection)]
1650 pub config: Option<Box<RawValue>>,
1651}
1652
1653impl PartialEq for HookRuntimeConfig {
1654 fn eq(&self, other: &Self) -> bool {
1655 self.kind == other.kind
1656 && self.config.as_ref().map(|raw| raw.get())
1657 == other.config.as_ref().map(|raw| raw.get())
1658 }
1659}
1660
1661impl HookRuntimeConfig {
1662 pub fn new(kind: impl Into<String>, config: Option<Value>) -> Result<Self, serde_json::Error> {
1663 let config = match config {
1664 Some(value) => Some(raw_json_from_value(value)?),
1665 None => None,
1666 };
1667 Ok(Self {
1668 kind: kind.into(),
1669 config,
1670 })
1671 }
1672
1673 pub fn config_value(&self) -> Result<Value, serde_json::Error> {
1674 match &self.config {
1675 Some(raw) => serde_json::from_str(raw.get()),
1676 None => Ok(Value::Null),
1677 }
1678 }
1679}
1680
1681impl Default for HookRuntimeConfig {
1682 fn default() -> Self {
1683 Self::new("in_process", Some(serde_json::json!({"name":"noop"}))).unwrap_or_else(|_| Self {
1684 kind: "in_process".to_string(),
1685 config: None,
1686 })
1687 }
1688}
1689
1690impl Serialize for HookRuntimeConfig {
1691 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1692 where
1693 S: serde::Serializer,
1694 {
1695 let mut map = Map::new();
1696 map.insert("type".to_string(), Value::String(self.kind.clone()));
1697
1698 if let Some(raw) = &self.config {
1699 let parsed: Value =
1700 serde_json::from_str(raw.get()).map_err(serde::ser::Error::custom)?;
1701 match parsed {
1702 Value::Object(obj) => {
1703 for (key, value) in obj {
1704 map.insert(key, value);
1705 }
1706 }
1707 other => {
1708 map.insert("config".to_string(), other);
1709 }
1710 }
1711 }
1712
1713 Value::Object(map).serialize(serializer)
1714 }
1715}
1716
1717impl<'de> Deserialize<'de> for HookRuntimeConfig {
1718 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1719 where
1720 D: serde::Deserializer<'de>,
1721 {
1722 let value = Value::deserialize(deserializer)?;
1723 let mut obj = value
1724 .as_object()
1725 .cloned()
1726 .ok_or_else(|| serde::de::Error::custom("hook runtime must be an object"))?;
1727
1728 let kind = obj
1729 .remove("type")
1730 .and_then(|value| value.as_str().map(ToOwned::to_owned))
1731 .ok_or_else(|| {
1732 serde::de::Error::custom("hook runtime missing required field 'type'")
1733 })?;
1734
1735 let config_value = if let Some(explicit) = obj.remove("config") {
1736 if obj.is_empty() {
1737 explicit
1738 } else {
1739 obj.insert("config".to_string(), explicit);
1740 Value::Object(obj)
1741 }
1742 } else if obj.is_empty() {
1743 Value::Null
1744 } else {
1745 Value::Object(obj)
1746 };
1747
1748 let config = if config_value.is_null() {
1749 None
1750 } else {
1751 Some(raw_json_from_value(config_value).map_err(serde::de::Error::custom)?)
1752 };
1753
1754 Ok(Self { kind, config })
1755 }
1756}
1757
1758impl JsonSchema for HookRuntimeConfig {
1759 fn schema_name() -> std::borrow::Cow<'static, str> {
1760 "HookRuntimeConfig".into()
1761 }
1762
1763 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1764 schemars::json_schema!({
1765 "type": "object",
1766 "required": ["type"],
1767 "properties": {
1768 "type": { "type": "string" },
1769 "config": {}
1770 },
1771 "additionalProperties": true
1772 })
1773 }
1774}
1775
1776fn raw_json_from_value(value: Value) -> Result<Box<RawValue>, serde_json::Error> {
1777 RawValue::from_string(serde_json::to_string(&value)?)
1778}
1779
1780#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1782#[serde(rename_all = "snake_case")]
1783pub enum ConfigScope {
1784 Global,
1785 Project,
1786}
1787
1788#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1790#[serde(transparent)]
1791pub struct ConfigDelta(pub serde_json::Value);
1792
1793#[derive(Debug, thiserror::Error)]
1795pub enum ConfigError {
1796 #[error("IO error: {0}")]
1797 Io(#[from] std::io::Error),
1798
1799 #[error("Parse error: {0}")]
1800 Parse(#[from] toml::de::Error),
1801
1802 #[error("TOML serialization error: {0}")]
1803 TomlSerialize(#[from] toml::ser::Error),
1804
1805 #[error("JSON error: {0}")]
1806 Json(#[from] serde_json::Error),
1807
1808 #[error("UTF-8 error: {0}")]
1809 Utf8(#[from] std::string::FromUtf8Error),
1810
1811 #[allow(dead_code)]
1812 #[error("Invalid value for {0}")]
1813 InvalidValue(String),
1814
1815 #[error("Missing required field: {0}")]
1816 MissingField(String),
1817
1818 #[error("Missing API key: {0}")]
1819 MissingApiKey(&'static str),
1820
1821 #[error("Internal error: {0}")]
1822 InternalError(String),
1823
1824 #[error("Validation error: {0}")]
1825 Validation(String),
1826}
1827
1828mod optional_duration_serde {
1830 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1831 use std::time::Duration;
1832
1833 #[allow(clippy::ref_option)]
1836 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
1837 where
1838 S: Serializer,
1839 {
1840 match duration {
1841 Some(d) => {
1842 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1843 s.serialize(serializer)
1844 }
1845 None => serializer.serialize_none(),
1846 }
1847 }
1848
1849 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
1850 where
1851 D: Deserializer<'de>,
1852 {
1853 use serde::de::Error;
1854
1855 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
1857 match value {
1858 None => Ok(None),
1859 Some(serde_json::Value::String(s)) => {
1860 humantime_serde::re::humantime::parse_duration(&s)
1861 .map(Some)
1862 .map_err(|e| D::Error::custom(e.to_string()))
1863 }
1864 Some(serde_json::Value::Number(n)) => {
1865 let millis = n
1867 .as_u64()
1868 .ok_or_else(|| D::Error::custom("invalid number"))?;
1869 Ok(Some(Duration::from_millis(millis)))
1870 }
1871 _ => Err(D::Error::custom("expected string or number for duration")),
1872 }
1873 }
1874}
1875
1876pub fn find_project_root(start_dir: &std::path::Path) -> Option<PathBuf> {
1878 let mut current = start_dir.to_path_buf();
1879 loop {
1880 if current.join(".rkat").is_dir() {
1881 return Some(current);
1882 }
1883 if !current.pop() {
1884 return None;
1885 }
1886 }
1887}
1888
1889pub fn data_dir() -> Option<PathBuf> {
1895 if let Ok(cwd) = std::env::current_dir()
1897 && let Some(root) = find_project_root(&cwd)
1898 {
1899 return Some(root.join(".rkat"));
1900 }
1901
1902 dirs::home_dir().map(|h| h.join(".rkat"))
1904}
1905
1906pub mod dirs {
1908 use std::path::PathBuf;
1909
1910 pub fn home_dir() -> Option<PathBuf> {
1911 std::env::var_os("HOME").map(PathBuf::from)
1912 }
1913}
1914
1915#[cfg(test)]
1916#[allow(clippy::unwrap_used, clippy::expect_used)]
1917mod tests {
1918 use super::*;
1919 use crate::Provider;
1920
1921 #[test]
1922 fn test_config_default() {
1923 let config = Config::default();
1924 assert_eq!(config.agent.model, "claude-opus-4-6");
1925 assert_eq!(config.agent.max_tokens_per_turn, 16384);
1926 assert_eq!(config.retry.max_retries, 3);
1927 }
1928
1929 #[test]
1930 fn test_config_layering() {
1931 let config = Config::default();
1933 assert_eq!(config.agent.model, "claude-opus-4-6");
1934 assert_eq!(config.budget.max_tokens, None);
1935
1936 {
1938 let env = std::collections::HashMap::from([
1939 ("RKAT_MODEL".to_string(), "env-model".to_string()),
1940 ("ANTHROPIC_API_KEY".to_string(), "secret-key".to_string()),
1941 ]);
1942 let mut config = Config::default();
1943 config
1944 .apply_env_overrides_from(|key| env.get(key).cloned())
1945 .expect("apply env overrides");
1946 match config.provider {
1947 ProviderConfig::Anthropic { api_key, .. } => {
1948 assert_eq!(api_key.as_deref(), Some("secret-key"));
1949 }
1950 _ => unreachable!("expected anthropic provider"),
1951 }
1952 }
1953
1954 let mut config = Config::default();
1956 let file_config = Config {
1957 agent: AgentConfig {
1958 model: "file-model".to_string(),
1959 ..Default::default()
1960 },
1961 ..Default::default()
1962 };
1963 config.merge(file_config);
1964 assert_eq!(config.agent.model, "file-model");
1965
1966 let mut config = Config::default();
1968 config.apply_cli_overrides(CliOverrides {
1969 model: Some("cli-model".to_string()),
1970 max_tokens: Some(50000),
1971 ..Default::default()
1972 });
1973 assert_eq!(config.agent.model, "cli-model");
1975 assert_eq!(config.budget.max_tokens, Some(50000));
1976 }
1977
1978 #[test]
1979 fn test_merge_extraction_prompt_survives_layering() {
1980 let mut base = Config::default();
1981 assert!(base.agent.extraction_prompt.is_none());
1982
1983 let toml = r#"
1985[agent]
1986extraction_prompt = "Return JSON only."
1987"#;
1988 base.merge_toml_str(toml).expect("merge toml");
1989 assert_eq!(
1990 base.agent.extraction_prompt.as_deref(),
1991 Some("Return JSON only.")
1992 );
1993
1994 let toml2 = r#"
1996[agent]
1997model = "custom-model"
1998"#;
1999 base.merge_toml_str(toml2).expect("merge toml2");
2000 assert_eq!(
2001 base.agent.extraction_prompt.as_deref(),
2002 Some("Return JSON only."),
2003 "extraction_prompt must survive merge when absent in later layer"
2004 );
2005 assert_eq!(base.agent.model, "custom-model");
2006 }
2007
2008 #[test]
2009 fn test_merge_hooks_entries_append() {
2010 let mut base = Config::default();
2011 let base_entry = HookEntryConfig {
2012 id: HookId::new("base"),
2013 ..HookEntryConfig::default()
2014 };
2015 base.hooks.entries.push(base_entry);
2016
2017 let mut other = Config::default();
2018 let other_entry = HookEntryConfig {
2019 id: HookId::new("other"),
2020 ..HookEntryConfig::default()
2021 };
2022 other.hooks.entries.push(other_entry);
2023
2024 base.merge(other);
2025 let ids = base
2026 .hooks
2027 .entries
2028 .iter()
2029 .map(|entry| entry.id.0.as_str())
2030 .collect::<Vec<_>>();
2031 assert_eq!(ids, vec!["base", "other"]);
2032 }
2033
2034 #[test]
2035 fn test_merge_self_hosted_preserves_lower_layer_servers_and_models() {
2036 let mut base = Config::default();
2037 base.merge_toml_str(
2038 r#"
2039[self_hosted.servers.local]
2040base_url = "http://127.0.0.1:11434"
2041"#,
2042 )
2043 .expect("base self-hosted server");
2044 base.merge_toml_str(
2045 r#"
2046[self_hosted.models.gemma-4-e2b]
2047server = "local"
2048remote_model = "gemma4:e2b"
2049display_name = "Gemma 4 E2B"
2050family = "gemma-4"
2051"#,
2052 )
2053 .expect("overlay self-hosted model");
2054
2055 assert!(base.self_hosted.servers.contains_key("local"));
2056 assert!(base.self_hosted.models.contains_key("gemma-4-e2b"));
2057 let registry = base.model_registry().expect("merged self-hosted registry");
2058 assert_eq!(
2059 registry
2060 .entry("gemma-4-e2b")
2061 .and_then(|entry| entry.self_hosted.as_ref())
2062 .map(|server| server.server_id.as_str()),
2063 Some("local")
2064 );
2065 }
2066
2067 #[test]
2068 fn test_merge_self_hosted_partial_server_override_preserves_existing_fields() {
2069 let mut config = Config::default();
2070 config
2071 .merge_toml_str(
2072 r#"
2073[self_hosted.servers.local]
2074base_url = "http://127.0.0.1:11434"
2075api_style = "responses"
2076"#,
2077 )
2078 .expect("base server");
2079 config
2080 .merge_toml_str(
2081 r#"
2082[self_hosted.servers.local]
2083bearer_token_env = "OLLAMA_TOKEN"
2084"#,
2085 )
2086 .expect("overlay server");
2087
2088 let server = config
2089 .self_hosted
2090 .servers
2091 .get("local")
2092 .expect("merged server");
2093 assert_eq!(server.base_url, "http://127.0.0.1:11434");
2094 assert_eq!(server.api_style, SelfHostedApiStyle::Responses);
2095 assert_eq!(server.bearer_token_env.as_deref(), Some("OLLAMA_TOKEN"));
2096 }
2097
2098 #[test]
2099 fn test_merge_self_hosted_partial_override_preserves_unrelated_inherited_entries() {
2100 let mut config = Config::default();
2101 config
2102 .merge_toml_str(
2103 r#"
2104[self_hosted.servers.local]
2105base_url = "http://127.0.0.1:11434"
2106
2107[self_hosted.servers.backup]
2108base_url = "http://127.0.0.1:11435"
2109
2110[self_hosted.models.gemma-4-e2b]
2111server = "local"
2112remote_model = "gemma4:e2b"
2113display_name = "Gemma 4 E2B"
2114family = "gemma-4"
2115
2116[self_hosted.models.gemma-4-e4b]
2117server = "backup"
2118remote_model = "gemma4:e4b"
2119display_name = "Gemma 4 E4B"
2120family = "gemma-4"
2121"#,
2122 )
2123 .expect("base self-hosted config");
2124 config
2125 .merge_toml_str(
2126 r#"
2127[self_hosted.servers.local]
2128bearer_token_env = "OLLAMA_TOKEN"
2129"#,
2130 )
2131 .expect("overlay self-hosted config");
2132
2133 assert!(config.self_hosted.servers.contains_key("backup"));
2134 assert!(config.self_hosted.models.contains_key("gemma-4-e4b"));
2135 let registry = config
2136 .model_registry()
2137 .expect("registry should remain valid");
2138 assert_eq!(
2139 registry
2140 .entry("gemma-4-e4b")
2141 .and_then(|entry| entry.self_hosted.as_ref())
2142 .map(|server| server.server_id.as_str()),
2143 Some("backup")
2144 );
2145 }
2146
2147 #[test]
2148 fn test_merge_self_hosted_empty_table_clears_inherited_entries() {
2149 let mut config = Config::default();
2150 config
2151 .merge_toml_str(
2152 r#"
2153[self_hosted.servers.local]
2154base_url = "http://127.0.0.1:11434"
2155
2156[self_hosted.models.gemma-4-e2b]
2157server = "local"
2158remote_model = "gemma4:e2b"
2159display_name = "Gemma 4 E2B"
2160family = "gemma-4"
2161"#,
2162 )
2163 .expect("base self-hosted config");
2164
2165 config
2166 .merge_toml_str(
2167 r"
2168[self_hosted.servers]
2169
2170[self_hosted.models]
2171",
2172 )
2173 .expect("clear self-hosted config");
2174
2175 assert!(config.self_hosted.servers.is_empty());
2176 assert!(config.self_hosted.models.is_empty());
2177 }
2178
2179 #[test]
2180 fn test_self_hosted_bearer_token_is_not_serialized() {
2181 let config: Config = toml::from_str(
2182 r#"
2183[self_hosted.servers.local]
2184base_url = "http://127.0.0.1:11434"
2185bearer_token = "secret-token"
2186"#,
2187 )
2188 .expect("config");
2189
2190 let value = serde_json::to_value(&config).expect("serialize config");
2191 let server = &value["self_hosted"]["servers"]["local"];
2192 assert!(
2193 server.get("bearer_token").is_none(),
2194 "literal bearer tokens must be redacted from serialized config"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_merge_providers_section_replaces_non_default() {
2200 let mut base = Config::default();
2201 base.providers.base_urls = Some(HashMap::from([
2202 ("anthropic".to_string(), "https://a.example".to_string()),
2203 ("openai".to_string(), "https://o.example".to_string()),
2204 ]));
2205
2206 let mut other = Config::default();
2207 other.providers.base_urls = Some(HashMap::from([(
2208 "openai".to_string(),
2209 "https://override.example".to_string(),
2210 )]));
2211
2212 base.merge(other);
2213 let urls = base
2214 .providers
2215 .base_urls
2216 .expect("providers.base_urls missing");
2217 assert_eq!(urls.len(), 1);
2218 assert_eq!(
2219 urls.get("openai").map(String::as_str),
2220 Some("https://override.example")
2221 );
2222 assert!(!urls.contains_key("anthropic"));
2223 }
2224
2225 #[test]
2226 fn test_merge_toml_tools_omitted_fields_preserve_lower_layer() {
2227 let mut config = Config::default();
2228 config.tools.mob_enabled = true;
2229 config.tools.shell_enabled = true;
2230
2231 config
2232 .merge_toml_str(
2233 r"
2234[tools]
2235shell_enabled = false
2236",
2237 )
2238 .expect("merge should succeed");
2239
2240 assert!(config.tools.mob_enabled);
2241 assert!(!config.tools.shell_enabled);
2242 }
2243
2244 #[test]
2245 fn test_merge_toml_tools_explicit_default_overrides_lower_layer() {
2246 let mut config = Config::default();
2247 config.tools.mob_enabled = true;
2248
2249 config
2250 .merge_toml_str(
2251 r"
2252[tools]
2253mob_enabled = false
2254",
2255 )
2256 .expect("merge should succeed");
2257
2258 assert!(!config.tools.mob_enabled);
2259 }
2260
2261 #[test]
2262 fn test_merge_toml_retry_omitted_fields_preserve_lower_layer() {
2263 let mut config = Config::default();
2264 config.retry.max_retries = 9;
2265
2266 config
2267 .merge_toml_str(
2268 r#"
2269[retry]
2270initial_delay = "750ms"
2271"#,
2272 )
2273 .expect("merge should succeed");
2274
2275 assert_eq!(config.retry.max_retries, 9);
2276 assert_eq!(config.retry.initial_delay, Duration::from_millis(750));
2277 }
2278
2279 #[test]
2280 fn test_validate_rejects_zero_min_turns_between_compactions() {
2281 let config = Config {
2282 compaction: CompactionRuntimeConfig {
2283 min_turns_between_compactions: 0,
2284 ..CompactionRuntimeConfig::default()
2285 },
2286 ..Config::default()
2287 };
2288 let err = config
2289 .validate()
2290 .expect_err("min_turns_between_compactions=0 should be invalid");
2291 assert!(
2292 err.to_string()
2293 .contains("compaction.min_turns_between_compactions")
2294 );
2295 }
2296
2297 #[test]
2298 fn test_provider_config_serialization() {
2299 let anthropic = ProviderConfig::Anthropic {
2300 api_key: Some("sk-test".to_string()),
2301 base_url: None,
2302 };
2303
2304 let json = serde_json::to_value(&anthropic).unwrap();
2305 assert_eq!(json["type"], "anthropic");
2306 assert_eq!(json["api_key"], "sk-test");
2307
2308 let openai = ProviderConfig::OpenAI {
2309 api_key: Some("sk-openai".to_string()),
2310 base_url: Some("https://custom.openai.com".to_string()),
2311 };
2312
2313 let json = serde_json::to_value(&openai).unwrap();
2314 assert_eq!(json["type"], "openai");
2315
2316 let gemini = ProviderConfig::Gemini {
2317 api_key: Some("gemini-key".to_string()),
2318 };
2319
2320 let json = serde_json::to_value(&gemini).unwrap();
2321 assert_eq!(json["type"], "gemini");
2322 }
2323
2324 #[test]
2325 fn test_budget_config_serialization() {
2326 let budget = BudgetConfig {
2327 max_tokens: Some(100_000),
2328 max_duration: Some(Duration::from_secs(300)),
2329 max_tool_calls: Some(50),
2330 };
2331
2332 let json = serde_json::to_string(&budget).unwrap();
2333 let parsed: BudgetConfig = serde_json::from_str(&json).unwrap();
2334
2335 assert_eq!(parsed.max_tokens, Some(100_000));
2336 assert_eq!(parsed.max_duration, Some(Duration::from_secs(300)));
2337 assert_eq!(parsed.max_tool_calls, Some(50));
2338 }
2339
2340 #[test]
2341 fn test_self_hosted_transport_accepts_openai_compatible_alias() {
2342 let mut config = Config::default();
2343 config
2344 .merge_toml_str(
2345 r#"
2346[self_hosted.servers.ollama]
2347transport = "openai_compatible"
2348base_url = "http://127.0.0.1:11434"
2349api_style = "chat_completions"
2350"#,
2351 )
2352 .expect("alias should parse");
2353
2354 assert_eq!(
2355 config
2356 .self_hosted
2357 .servers
2358 .get("ollama")
2359 .expect("server should exist")
2360 .transport,
2361 SelfHostedTransport::OpenAiCompatible
2362 );
2363 }
2364
2365 #[test]
2366 fn test_self_hosted_server_config_defaults_to_chat_completions() {
2367 assert_eq!(
2368 SelfHostedServerConfig::default().api_style,
2369 SelfHostedApiStyle::ChatCompletions
2370 );
2371 }
2372
2373 #[test]
2374 fn test_retry_config_to_policy() {
2375 let config = RetryConfig::default();
2376 let policy: RetryPolicy = config.into();
2377
2378 assert_eq!(policy.max_retries, 3);
2379 assert_eq!(policy.initial_delay, Duration::from_millis(500));
2380 }
2381
2382 #[tokio::test]
2386 async fn test_regression_load_succeeds_without_config_toml() {
2387 use tempfile::TempDir;
2388
2389 let temp_dir = TempDir::new().unwrap();
2391 let rkat_dir = temp_dir.path().join(".rkat");
2392 std::fs::create_dir(&rkat_dir).unwrap();
2393
2394 assert!(rkat_dir.exists());
2396 assert!(!rkat_dir.join("config.toml").exists());
2397
2398 let result =
2400 Config::load_from_with_env(temp_dir.path(), Some(temp_dir.path()), |_| None).await;
2401
2402 assert!(
2403 result.is_ok(),
2404 "Config::load() should succeed when .rkat/ exists without config.toml: {:?}",
2405 result.err()
2406 );
2407 }
2408
2409 #[test]
2410 fn test_validate_rejects_zero_max_tokens() {
2411 let config = Config {
2412 max_tokens: 0,
2413 ..Config::default()
2414 };
2415 let err = config
2416 .validate()
2417 .expect_err("max_tokens=0 should be invalid");
2418 assert!(
2419 err.to_string()
2420 .contains("max_tokens must be greater than 0")
2421 );
2422 }
2423
2424 #[test]
2425 fn test_validate_rejects_zero_agent_max_tokens_per_turn() {
2426 let mut config = Config::default();
2427 config.agent.max_tokens_per_turn = 0;
2428 let err = config
2429 .validate()
2430 .expect_err("agent.max_tokens_per_turn=0 should be invalid");
2431 assert!(err.to_string().contains("agent.max_tokens_per_turn"));
2432 }
2433
2434 #[test]
2435 fn test_validate_rejects_provider_base_url_conflict() {
2436 let mut config = Config {
2437 provider: ProviderConfig::OpenAI {
2438 api_key: None,
2439 base_url: Some("https://one.example".to_string()),
2440 },
2441 ..Config::default()
2442 };
2443 config.providers.base_urls = Some(std::collections::HashMap::from([(
2444 "openai".to_string(),
2445 "https://two.example".to_string(),
2446 )]));
2447 let err = config
2448 .validate()
2449 .expect_err("conflicting base_url settings should be invalid");
2450 assert!(err.to_string().contains("provider base_url conflicts"));
2451 }
2452
2453 #[test]
2454 fn test_validate_rejects_provider_api_key_conflict() {
2455 let mut config = Config {
2456 provider: ProviderConfig::Gemini {
2457 api_key: Some("one".to_string()),
2458 },
2459 ..Config::default()
2460 };
2461 config.providers.api_keys = Some(std::collections::HashMap::from([(
2462 "gemini".to_string(),
2463 "two".to_string(),
2464 )]));
2465 let err = config
2466 .validate()
2467 .expect_err("conflicting api_key settings should be invalid");
2468 assert!(err.to_string().contains("provider api_key conflicts"));
2469 }
2470
2471 #[test]
2472 fn test_provider_parse_strict() {
2473 assert_eq!(
2474 Provider::parse_strict("anthropic"),
2475 Some(Provider::Anthropic)
2476 );
2477 assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
2478 assert_eq!(Provider::parse_strict("gemini"), Some(Provider::Gemini));
2479 assert_eq!(Provider::parse_strict("other"), None);
2480 assert_eq!(Provider::parse_strict("claude"), None);
2481 assert_eq!(Provider::parse_strict(""), None);
2482 }
2483
2484 #[test]
2485 fn test_provider_infer_from_model() {
2486 assert_eq!(
2487 Provider::infer_from_model("claude-opus-4-6"),
2488 Some(Provider::Anthropic)
2489 );
2490 assert_eq!(
2491 Provider::infer_from_model("gpt-5.2"),
2492 Some(Provider::OpenAI)
2493 );
2494 assert_eq!(
2495 Provider::infer_from_model("gemini-3-flash-preview"),
2496 Some(Provider::Gemini)
2497 );
2498 assert_eq!(Provider::infer_from_model("llama-3"), None);
2499 assert_eq!(Provider::infer_from_model(""), None);
2500 }
2501
2502 #[test]
2505 fn test_comms_auth_mode_default_is_open() {
2506 assert_eq!(CommsAuthMode::default(), CommsAuthMode::Open);
2507 }
2508
2509 #[test]
2510 fn test_comms_auth_mode_serde_roundtrip() {
2511 let json = serde_json::to_string(&CommsAuthMode::Open).unwrap();
2513 assert_eq!(json, r#""none""#);
2514 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2515 assert_eq!(parsed, CommsAuthMode::Open);
2516
2517 let json = serde_json::to_string(&CommsAuthMode::Ed25519).unwrap();
2519 assert_eq!(json, r#""ed25519""#);
2520 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2521 assert_eq!(parsed, CommsAuthMode::Ed25519);
2522 }
2523
2524 #[test]
2525 fn test_comms_auth_mode_toml_roundtrip() {
2526 let config = CommsRuntimeConfig::default();
2527 let toml_str = toml::to_string(&config).unwrap();
2528 let parsed: CommsRuntimeConfig = toml::from_str(&toml_str).unwrap();
2529 assert_eq!(parsed.auth, CommsAuthMode::Open);
2530 assert!(parsed.require_peer_auth);
2531
2532 let toml_str = r#"
2534mode = "inproc"
2535auth = "ed25519"
2536"#;
2537 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2538 assert_eq!(parsed.auth, CommsAuthMode::Ed25519);
2539 assert!(parsed.require_peer_auth);
2540 }
2541
2542 #[test]
2543 fn test_comms_runtime_config_default_has_open_auth() {
2544 let config = CommsRuntimeConfig::default();
2545 assert_eq!(config.auth, CommsAuthMode::Open);
2546 assert!(config.require_peer_auth);
2547 }
2548
2549 #[test]
2552 fn test_plain_event_source_serde_roundtrip() {
2553 let cases = [
2554 (PlainEventSource::Tcp, r#""tcp""#),
2555 (PlainEventSource::Uds, r#""uds""#),
2556 (PlainEventSource::Stdin, r#""stdin""#),
2557 (PlainEventSource::Webhook, r#""webhook""#),
2558 (PlainEventSource::Rpc, r#""rpc""#),
2559 ];
2560 for (variant, expected_json) in cases {
2561 let json = serde_json::to_string(&variant).unwrap();
2562 assert_eq!(json, expected_json, "serialize {variant:?}");
2563 let parsed: PlainEventSource = serde_json::from_str(&json).unwrap();
2564 assert_eq!(parsed, variant, "deserialize {variant:?}");
2565 }
2566 }
2567
2568 #[test]
2569 fn test_plain_event_source_display() {
2570 assert_eq!(PlainEventSource::Tcp.to_string(), "tcp");
2571 assert_eq!(PlainEventSource::Uds.to_string(), "uds");
2572 assert_eq!(PlainEventSource::Stdin.to_string(), "stdin");
2573 assert_eq!(PlainEventSource::Webhook.to_string(), "webhook");
2574 assert_eq!(PlainEventSource::Rpc.to_string(), "rpc");
2575 }
2576
2577 #[test]
2580 fn test_comms_config_event_address_toml_roundtrip() {
2581 let toml_str = r#"
2582mode = "tcp"
2583address = "127.0.0.1:4200"
2584auth = "none"
2585require_peer_auth = false
2586event_address = "127.0.0.1:4201"
2587"#;
2588 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2589 assert_eq!(parsed.event_address.as_deref(), Some("127.0.0.1:4201"));
2590 assert_eq!(parsed.auth, CommsAuthMode::Open);
2591 assert!(!parsed.require_peer_auth);
2592 }
2593
2594 #[test]
2595 fn test_comms_config_event_address_defaults_none() {
2596 let config = CommsRuntimeConfig::default();
2597 assert!(config.event_address.is_none());
2598 }
2599
2600 #[test]
2603 fn call_timeout_override_default_is_inherit() {
2604 assert_eq!(CallTimeoutOverride::default(), CallTimeoutOverride::Inherit);
2605 assert!(CallTimeoutOverride::default().is_inherit());
2606 }
2607
2608 #[test]
2609 fn call_timeout_override_disabled_is_not_inherit() {
2610 assert!(!CallTimeoutOverride::Disabled.is_inherit());
2611 }
2612
2613 #[test]
2614 fn call_timeout_override_value_is_not_inherit() {
2615 assert!(!CallTimeoutOverride::Value(Duration::from_secs(45)).is_inherit());
2616 }
2617
2618 #[test]
2619 fn call_timeout_override_toml_deserialize_disabled() {
2620 let toml_str = r#"call_timeout = "disabled""#;
2621 #[derive(Deserialize)]
2622 struct Wrapper {
2623 call_timeout: CallTimeoutOverride,
2624 }
2625 let w: Wrapper = toml::from_str(toml_str).unwrap();
2626 assert_eq!(w.call_timeout, CallTimeoutOverride::Disabled);
2627 }
2628
2629 #[test]
2630 fn call_timeout_override_toml_deserialize_duration() {
2631 let toml_str = r#"call_timeout = "45s""#;
2632 #[derive(Deserialize)]
2633 struct Wrapper {
2634 call_timeout: CallTimeoutOverride,
2635 }
2636 let w: Wrapper = toml::from_str(toml_str).unwrap();
2637 assert_eq!(
2638 w.call_timeout,
2639 CallTimeoutOverride::Value(Duration::from_secs(45))
2640 );
2641 }
2642
2643 #[test]
2644 fn call_timeout_override_toml_deserialize_complex_duration() {
2645 let toml_str = r#"call_timeout = "2m 30s""#;
2646 #[derive(Deserialize)]
2647 struct Wrapper {
2648 call_timeout: CallTimeoutOverride,
2649 }
2650 let w: Wrapper = toml::from_str(toml_str).unwrap();
2651 assert_eq!(
2652 w.call_timeout,
2653 CallTimeoutOverride::Value(Duration::from_secs(150))
2654 );
2655 }
2656
2657 #[test]
2658 fn retry_config_default_has_inherit_call_timeout() {
2659 let config = RetryConfig::default();
2660 assert_eq!(config.call_timeout_override, CallTimeoutOverride::Inherit);
2661 }
2662
2663 #[test]
2664 fn retry_config_from_toml_with_call_timeout_value() {
2665 let toml_str = r#"
2666[retry]
2667max_retries = 5
2668call_timeout = "60s"
2669"#;
2670 let config: Config = toml::from_str(toml_str).unwrap();
2671 assert_eq!(config.retry.max_retries, 5);
2672 assert_eq!(
2673 config.retry.call_timeout_override,
2674 CallTimeoutOverride::Value(Duration::from_secs(60))
2675 );
2676 }
2677
2678 #[test]
2679 fn retry_config_from_toml_with_call_timeout_disabled() {
2680 let toml_str = r#"
2681[retry]
2682call_timeout = "disabled"
2683"#;
2684 let config: Config = toml::from_str(toml_str).unwrap();
2685 assert_eq!(
2686 config.retry.call_timeout_override,
2687 CallTimeoutOverride::Disabled
2688 );
2689 }
2690
2691 #[test]
2692 fn retry_config_from_toml_omitted_is_inherit() {
2693 let toml_str = r"
2694[retry]
2695max_retries = 2
2696";
2697 let config: Config = toml::from_str(toml_str).unwrap();
2698 assert_eq!(
2699 config.retry.call_timeout_override,
2700 CallTimeoutOverride::Inherit
2701 );
2702 }
2703
2704 #[test]
2705 fn retry_policy_from_config_with_value_override() {
2706 let config = RetryConfig {
2707 call_timeout_override: CallTimeoutOverride::Value(Duration::from_secs(90)),
2708 ..RetryConfig::default()
2709 };
2710 let policy: crate::retry::RetryPolicy = config.into();
2711 assert_eq!(policy.call_timeout, Some(Duration::from_secs(90)));
2712 }
2713
2714 #[test]
2715 fn retry_policy_from_config_with_disabled_override() {
2716 let config = RetryConfig {
2717 call_timeout_override: CallTimeoutOverride::Disabled,
2718 ..RetryConfig::default()
2719 };
2720 let policy: crate::retry::RetryPolicy = config.into();
2721 assert_eq!(policy.call_timeout, None);
2723 }
2724
2725 #[test]
2726 fn retry_policy_from_config_with_inherit_override() {
2727 let config = RetryConfig {
2728 call_timeout_override: CallTimeoutOverride::Inherit,
2729 ..RetryConfig::default()
2730 };
2731 let policy: crate::retry::RetryPolicy = config.into();
2732 assert_eq!(policy.call_timeout, None);
2733 }
2734
2735 #[test]
2736 fn config_merge_preserves_call_timeout_override() {
2737 let toml_base = r"
2738[retry]
2739max_retries = 2
2740";
2741 let toml_overlay = r#"
2742[retry]
2743call_timeout = "30s"
2744"#;
2745 let mut config: Config = toml::from_str(toml_base).unwrap();
2746 let overlay: Config = toml::from_str(toml_overlay).unwrap();
2747 let overlay_parsed: toml::Value = toml::from_str(toml_overlay).unwrap();
2748 config.merge_retry_from_toml_presence(&overlay_parsed, &overlay.retry);
2749 assert_eq!(config.retry.max_retries, 2); assert_eq!(
2751 config.retry.call_timeout_override,
2752 CallTimeoutOverride::Value(Duration::from_secs(30))
2753 );
2754 }
2755
2756 #[test]
2759 fn test_provider_tools_defaults_all_enabled() {
2760 let config = Config::default();
2761 assert!(config.provider_tools.anthropic.web_search);
2762 assert!(config.provider_tools.openai.web_search);
2763 assert!(config.provider_tools.gemini.google_search);
2764 }
2765
2766 #[test]
2767 fn test_provider_tools_roundtrip_toml() {
2768 let config = Config::default();
2769 let toml_str = toml::to_string(&config.provider_tools).unwrap();
2770 let parsed: ProviderToolsConfig = toml::from_str(&toml_str).unwrap();
2771 assert_eq!(parsed, config.provider_tools);
2772 }
2773
2774 #[test]
2775 fn test_provider_tools_merge_preserves_when_absent() {
2776 let mut config = Config::default();
2777 config
2778 .merge_toml_str(
2779 r#"[agent]
2780model = "custom-model"
2781"#,
2782 )
2783 .unwrap();
2784 assert!(config.provider_tools.anthropic.web_search);
2786 assert!(config.provider_tools.openai.web_search);
2787 assert!(config.provider_tools.gemini.google_search);
2788 }
2789
2790 #[test]
2791 fn test_provider_tools_merge_overrides_single_provider() {
2792 let mut config = Config::default();
2793 config
2794 .merge_toml_str("[provider_tools.anthropic]\nweb_search = false\n")
2795 .unwrap();
2796 assert!(!config.provider_tools.anthropic.web_search);
2798 assert!(config.provider_tools.openai.web_search);
2800 assert!(config.provider_tools.gemini.google_search);
2801 }
2802
2803 #[test]
2806 fn test_provider_tool_defaults_not_serialized() {
2807 let agent_config = AgentConfig {
2808 provider_tool_defaults: Some(
2809 serde_json::json!({"web_search": {"type": "web_search_20250305"}}),
2810 ),
2811 ..Default::default()
2812 };
2813 let json = serde_json::to_value(&agent_config).unwrap();
2814 assert!(
2815 json.get("provider_tool_defaults").is_none(),
2816 "provider_tool_defaults must not be serialized: {json}"
2817 );
2818 }
2819}