1use crate::connection::RealmConfigSection;
6use crate::mcp_config::McpServerConfig;
7use crate::model_profile::catalog::ModelTier;
8use crate::{
9 budget::BudgetLimits,
10 hooks::{HookCapability, HookExecutionMode, HookId, HookPoint},
11 retry::RetryPolicy,
12 types::{OutputSchema, SecurityMode},
13};
14use schemars::JsonSchema;
15use serde::de::Deserializer;
16use serde::ser::SerializeStruct;
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19use std::collections::{BTreeMap, HashMap};
20use std::path::PathBuf;
21use std::sync::OnceLock;
22use std::time::Duration;
23
24pub const DEFAULT_MAX_SESSIONS: usize = 100_000;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct Config {
31 pub agent: AgentConfig,
32 pub storage: StorageConfig,
33 pub budget: BudgetConfig,
34 pub retry: RetryConfig,
35 pub tools: ToolsConfig,
36 pub models: ModelDefaults,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub max_tokens: Option<u32>,
49 pub shell: ShellDefaults,
50 pub store: StoreConfig,
51 pub comms: CommsRuntimeConfig,
52 pub compaction: CompactionRuntimeConfig,
53 pub limits: LimitsConfig,
54 pub rest: RestServerConfig,
55 pub hooks: HooksConfig,
56 pub skills: crate::skills_config::SkillsConfig,
57 pub self_hosted: SelfHostedConfig,
58 pub provider_tools: ProviderToolsConfig,
59 pub model_fallback: ModelFallbackConfig,
60 pub presentation: PresentationConfig,
61 #[serde(rename = "realm", default, skip_serializing_if = "BTreeMap::is_empty")]
67 pub realm: BTreeMap<String, RealmConfigSection>,
68}
69
70impl Default for Config {
71 fn default() -> Self {
72 let agent = AgentConfig::default();
73 Self {
74 agent,
75 storage: StorageConfig::default(),
76 budget: BudgetConfig::default(),
77 retry: RetryConfig::default(),
78 tools: ToolsConfig::default(),
79 models: ModelDefaults::default(),
80 max_tokens: None,
85 shell: ShellDefaults::default(),
86 store: StoreConfig::default(),
87 comms: CommsRuntimeConfig::default(),
88 compaction: CompactionRuntimeConfig::default(),
89 limits: LimitsConfig::default(),
90 rest: RestServerConfig::default(),
91 hooks: HooksConfig::default(),
92 skills: crate::skills_config::SkillsConfig::default(),
93 self_hosted: SelfHostedConfig::default(),
94 provider_tools: ProviderToolsConfig::default(),
95 model_fallback: ModelFallbackConfig::default(),
96 presentation: PresentationConfig::default(),
97 realm: BTreeMap::new(),
98 }
99 }
100}
101
102impl Config {
103 pub fn max_sessions(&self) -> usize {
105 self.limits.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS)
106 }
107
108 pub fn model_registry(
112 &self,
113 catalog: crate::model_profile::ModelCatalog,
114 ) -> Result<crate::ModelRegistry, ConfigError> {
115 crate::ModelRegistry::from_config(self, catalog)
116 }
117
118 pub fn template_toml() -> &'static str {
120 CONFIG_TEMPLATE_TOML
121 }
122
123 pub fn template() -> Result<Self, ConfigError> {
125 toml::from_str(CONFIG_TEMPLATE_TOML).map_err(ConfigError::Parse)
126 }
127}
128
129#[cfg(not(target_arch = "wasm32"))]
131impl Config {
132 pub async fn load() -> Result<Self, ConfigError> {
136 let cwd = std::env::current_dir()?;
137 let home = dirs::home_dir();
138 Self::load_from_with_env(&cwd, home.as_deref(), |key| std::env::var(key).ok()).await
139 }
140
141 #[doc(hidden)]
147 pub async fn load_from_with_env<F>(
148 start_dir: &std::path::Path,
149 home_dir: Option<&std::path::Path>,
150 env: F,
151 ) -> Result<Self, ConfigError>
152 where
153 F: FnMut(&str) -> Option<String>,
154 {
155 let mut config = Self::default();
156
157 if let Some(path) = Self::find_project_config_from(start_dir).await {
159 config.merge_file(&path).await?;
160 } else if let Some(path) = home_dir.map(|home| home.join(".rkat/config.toml"))
161 && tokio::fs::try_exists(&path).await.unwrap_or(false)
162 {
163 config.merge_file(&path).await?;
164 }
165
166 config.apply_env_overrides_from(env)?;
168
169 Ok(config)
170 }
171
172 #[doc(hidden)]
174 pub async fn load_from(
175 start_dir: &std::path::Path,
176 home_dir: Option<&std::path::Path>,
177 ) -> Result<Self, ConfigError> {
178 Self::load_from_with_env(start_dir, home_dir, |key| std::env::var(key).ok()).await
179 }
180
181 pub async fn load_layered_hooks() -> Result<HooksConfig, ConfigError> {
186 let cwd = std::env::current_dir()?;
187 let home = dirs::home_dir();
188 Self::load_layered_hooks_from(&cwd, home.as_deref()).await
189 }
190
191 pub async fn load_layered_hooks_from(
193 start_dir: &std::path::Path,
194 home_dir: Option<&std::path::Path>,
195 ) -> Result<HooksConfig, ConfigError> {
196 let mut hooks = HooksConfig::default();
197
198 if let Some(global_path) = home_dir.map(|home| home.join(".rkat/config.toml"))
199 && tokio::fs::try_exists(&global_path).await.unwrap_or(false)
200 {
201 let content = tokio::fs::read_to_string(&global_path).await?;
202 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
203 hooks.append_entries_from(&cfg.hooks);
204 }
205
206 if let Some(project_path) = Self::find_project_config_from(start_dir).await
207 && tokio::fs::try_exists(&project_path).await.unwrap_or(false)
208 {
209 let content = tokio::fs::read_to_string(&project_path).await?;
210 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
211 hooks.append_entries_from(&cfg.hooks);
212 }
213
214 Ok(hooks)
215 }
216}
217
218impl Config {
219 pub fn budget_limits(&self) -> BudgetLimits {
221 self.limits.to_budget_limits()
222 }
223
224 #[cfg(not(target_arch = "wasm32"))]
226 pub fn global_config_path() -> Option<PathBuf> {
227 dirs::home_dir().map(|h| h.join(".rkat/config.toml"))
228 }
229
230 #[cfg(not(target_arch = "wasm32"))]
236 async fn find_project_config_from(start_dir: &std::path::Path) -> Option<PathBuf> {
237 let mut current = start_dir.to_path_buf();
238 loop {
239 let marker_dir = current.join(".rkat");
240 let config_path = marker_dir.join("config.toml");
241
242 let config_exists = tokio::fs::try_exists(&config_path).await.unwrap_or(false);
244 if config_exists {
245 return Some(config_path);
246 }
247
248 if !current.pop() {
249 return None;
250 }
251 }
252 }
253
254 #[cfg(not(target_arch = "wasm32"))]
256 pub async fn merge_file(&mut self, path: &PathBuf) -> Result<(), ConfigError> {
257 let content = tokio::fs::read_to_string(path).await?;
258 self.merge_toml_str(&content)
259 }
260
261 pub fn merge_toml_str(&mut self, content: &str) -> Result<(), ConfigError> {
263 let file_config: Config = toml::from_str(content).map_err(ConfigError::Parse)?;
264 let tools_layer = file_config.tools.clone();
265 let retry_layer = file_config.retry.clone();
266 let self_hosted_layer = file_config.self_hosted.clone();
267 let provider_tools_layer = file_config.provider_tools.clone();
268 self.merge(file_config);
270 let parsed: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
271 self.merge_tools_from_toml_presence(&parsed, &tools_layer);
272 self.merge_retry_from_toml_presence(&parsed, &retry_layer);
273 self.merge_self_hosted_from_toml_presence(&parsed, &self_hosted_layer);
274 self.merge_provider_tools_from_toml_presence(&parsed, &provider_tools_layer);
275 Ok(())
276 }
277
278 fn merge(&mut self, other: Config) {
288 if other.agent.system_prompt.is_some() {
290 self.agent.system_prompt = other.agent.system_prompt;
291 }
292 if other.agent.tool_instructions.is_some() {
293 self.agent.tool_instructions = other.agent.tool_instructions;
294 }
295 if other.agent.model != AgentConfig::default().model {
296 self.agent.model = other.agent.model;
297 }
298 if other.agent.max_tokens_per_turn.is_some() {
299 self.agent.max_tokens_per_turn = other.agent.max_tokens_per_turn;
300 }
301 if other.agent.extraction_prompt.is_some() {
302 self.agent.extraction_prompt = other.agent.extraction_prompt;
303 }
304
305 if other.storage.directory.is_some() {
307 self.storage.directory = other.storage.directory;
308 }
309
310 if other.budget.max_tokens.is_some() {
312 self.budget.max_tokens = other.budget.max_tokens;
313 }
314 if other.budget.max_duration.is_some() {
315 self.budget.max_duration = other.budget.max_duration;
316 }
317 if other.budget.max_tool_calls.is_some() {
318 self.budget.max_tool_calls = other.budget.max_tool_calls;
319 }
320 self.merge_retry(&other.retry);
321 self.merge_tools(&other.tools);
322
323 let default_models = ModelDefaults::default();
328 if other.models.anthropic != default_models.anthropic {
329 self.models.anthropic = other.models.anthropic;
330 }
331 if other.models.openai != default_models.openai {
332 self.models.openai = other.models.openai;
333 }
334 if other.models.gemini != default_models.gemini {
335 self.models.gemini = other.models.gemini;
336 }
337 for (id, entry) in other.models.custom {
338 self.models.custom.insert(id, entry);
339 }
340 if other.max_tokens.is_some() {
341 self.max_tokens = other.max_tokens;
342 }
343 if other.shell != ShellDefaults::default() {
344 self.shell = other.shell;
345 }
346 if other.store != StoreConfig::default() {
347 self.store = other.store;
348 }
349 if other.comms != CommsRuntimeConfig::default() {
350 self.comms = other.comms;
351 }
352 if other.compaction != CompactionRuntimeConfig::default() {
353 self.compaction = other.compaction;
354 }
355 if other.limits.budget.is_some() {
358 self.limits.budget = other.limits.budget;
359 }
360 if other.limits.max_sessions.is_some() {
361 self.limits.max_sessions = other.limits.max_sessions;
362 }
363 if other.limits.max_duration.is_some() {
364 self.limits.max_duration = other.limits.max_duration;
365 }
366 if other.rest != RestServerConfig::default() {
367 self.rest = other.rest;
368 }
369 if other.hooks != HooksConfig::default() {
370 let default_hooks = HooksConfig::default();
371 if other.hooks.default_timeout_ms != default_hooks.default_timeout_ms {
372 self.hooks.default_timeout_ms = other.hooks.default_timeout_ms;
373 }
374 if other.hooks.payload_max_bytes != default_hooks.payload_max_bytes {
375 self.hooks.payload_max_bytes = other.hooks.payload_max_bytes;
376 }
377 if other.hooks.background_max_concurrency != default_hooks.background_max_concurrency {
378 self.hooks.background_max_concurrency = other.hooks.background_max_concurrency;
379 }
380 self.hooks.entries.extend(other.hooks.entries);
381 }
382 if other.model_fallback.use_catalog_default_chain {
383 self.model_fallback = ModelFallbackConfig::default();
384 } else if other.model_fallback != ModelFallbackConfig::default() {
385 self.model_fallback = other.model_fallback;
386 }
387
388 let default_skills = crate::skills_config::SkillsConfig::default();
392 if other.skills.enabled != default_skills.enabled {
393 self.skills.enabled = other.skills.enabled;
394 }
395 if other.skills.max_injection_bytes != default_skills.max_injection_bytes {
396 self.skills.max_injection_bytes = other.skills.max_injection_bytes;
397 }
398 if other.skills.inventory_threshold != default_skills.inventory_threshold {
399 self.skills.inventory_threshold = other.skills.inventory_threshold;
400 }
401 for repo in other.skills.repositories {
402 if let Some(existing) = self
403 .skills
404 .repositories
405 .iter_mut()
406 .find(|existing| existing.name == repo.name)
407 {
408 *existing = repo;
409 } else {
410 self.skills.repositories.push(repo);
411 }
412 }
413
414 for (realm_id, section) in other.realm {
420 self.realm.insert(realm_id, section);
421 }
422 }
423
424 fn merge_retry(&mut self, other: &RetryConfig) {
425 let defaults = RetryConfig::default();
426 if other.max_retries != defaults.max_retries {
427 self.retry.max_retries = other.max_retries;
428 }
429 if other.initial_delay != defaults.initial_delay {
430 self.retry.initial_delay = other.initial_delay;
431 }
432 if other.max_delay != defaults.max_delay {
433 self.retry.max_delay = other.max_delay;
434 }
435 if other.multiplier != defaults.multiplier {
436 self.retry.multiplier = other.multiplier;
437 }
438 if other.call_timeout_override != defaults.call_timeout_override {
439 self.retry.call_timeout_override = other.call_timeout_override.clone();
440 }
441 }
442
443 fn merge_tools(&mut self, other: &ToolsConfig) {
444 let defaults = ToolsConfig::default();
445 for server in &other.mcp_servers {
449 if let Some(existing) = self
450 .tools
451 .mcp_servers
452 .iter_mut()
453 .find(|existing| existing.name == server.name)
454 {
455 existing.clone_from(server);
456 } else {
457 self.tools.mcp_servers.push(server.clone());
458 }
459 }
460 if other.default_timeout != defaults.default_timeout {
461 self.tools.default_timeout = other.default_timeout;
462 }
463 if other.tool_timeouts != defaults.tool_timeouts {
464 self.tools.tool_timeouts.clone_from(&other.tool_timeouts);
465 }
466 if other.max_concurrent != defaults.max_concurrent {
467 self.tools.max_concurrent = other.max_concurrent;
468 }
469 if other.builtins_enabled != defaults.builtins_enabled {
470 self.tools.builtins_enabled = other.builtins_enabled;
471 }
472 if other.shell_enabled != defaults.shell_enabled {
473 self.tools.shell_enabled = other.shell_enabled;
474 }
475 if other.comms_enabled != defaults.comms_enabled {
476 self.tools.comms_enabled = other.comms_enabled;
477 }
478 if other.mob_enabled != defaults.mob_enabled {
479 self.tools.mob_enabled = other.mob_enabled;
480 }
481 if other.schedule_enabled != defaults.schedule_enabled {
482 self.tools.schedule_enabled = other.schedule_enabled;
483 }
484 if other.workgraph_enabled != defaults.workgraph_enabled {
485 self.tools.workgraph_enabled = other.workgraph_enabled;
486 }
487 }
488
489 fn merge_tools_from_toml_presence(&mut self, parsed: &toml::Value, layer: &ToolsConfig) {
490 let Some(tools) = parsed.get("tools").and_then(toml::Value::as_table) else {
491 return;
492 };
493 if tools.contains_key("default_timeout") {
500 self.tools.default_timeout = layer.default_timeout;
501 }
502 if tools.contains_key("tool_timeouts") {
503 self.tools.tool_timeouts.clone_from(&layer.tool_timeouts);
504 }
505 if tools.contains_key("max_concurrent") {
506 self.tools.max_concurrent = layer.max_concurrent;
507 }
508 if tools.contains_key("builtins_enabled") {
509 self.tools.builtins_enabled = layer.builtins_enabled;
510 }
511 if tools.contains_key("shell_enabled") {
512 self.tools.shell_enabled = layer.shell_enabled;
513 }
514 if tools.contains_key("comms_enabled") {
515 self.tools.comms_enabled = layer.comms_enabled;
516 }
517 if tools.contains_key("mob_enabled") {
518 self.tools.mob_enabled = layer.mob_enabled;
519 }
520 if tools.contains_key("schedule_enabled") {
521 self.tools.schedule_enabled = layer.schedule_enabled;
522 }
523 if tools.contains_key("workgraph_enabled") {
524 self.tools.workgraph_enabled = layer.workgraph_enabled;
525 }
526 }
527
528 fn merge_retry_from_toml_presence(&mut self, parsed: &toml::Value, layer: &RetryConfig) {
529 let Some(retry) = parsed.get("retry").and_then(toml::Value::as_table) else {
530 return;
531 };
532 if retry.contains_key("max_retries") {
533 self.retry.max_retries = layer.max_retries;
534 }
535 if retry.contains_key("initial_delay") {
536 self.retry.initial_delay = layer.initial_delay;
537 }
538 if retry.contains_key("max_delay") {
539 self.retry.max_delay = layer.max_delay;
540 }
541 if retry.contains_key("multiplier") {
542 self.retry.multiplier = layer.multiplier;
543 }
544 if retry.contains_key("call_timeout") {
545 self.retry.call_timeout_override = layer.call_timeout_override.clone();
546 }
547 }
548
549 fn merge_provider_tools_from_toml_presence(
550 &mut self,
551 parsed: &toml::Value,
552 layer: &ProviderToolsConfig,
553 ) {
554 let Some(pt) = parsed.get("provider_tools").and_then(toml::Value::as_table) else {
555 return;
556 };
557 if let Some(anthropic) = pt.get("anthropic").and_then(toml::Value::as_table)
558 && anthropic.contains_key("web_search")
559 {
560 self.provider_tools.anthropic.web_search = layer.anthropic.web_search;
561 }
562 if let Some(openai) = pt.get("openai").and_then(toml::Value::as_table)
563 && openai.contains_key("web_search")
564 {
565 self.provider_tools.openai.web_search = layer.openai.web_search;
566 }
567 if let Some(gemini) = pt.get("gemini").and_then(toml::Value::as_table)
568 && gemini.contains_key("google_search")
569 {
570 self.provider_tools.gemini.google_search = layer.gemini.google_search;
571 }
572 }
573
574 fn merge_self_hosted_from_toml_presence(
575 &mut self,
576 parsed: &toml::Value,
577 layer: &SelfHostedConfig,
578 ) {
579 let Some(self_hosted) = parsed.get("self_hosted").and_then(toml::Value::as_table) else {
580 return;
581 };
582
583 if self_hosted.contains_key("default_model") {
587 self.self_hosted.default_model = layer.default_model.clone();
588 }
589
590 if let Some(servers) = self_hosted.get("servers").and_then(toml::Value::as_table) {
591 if servers.is_empty() {
592 self.self_hosted.servers.clear();
593 self.self_hosted.models.clear();
594 } else {
595 let mut merged_servers = self.self_hosted.servers.clone();
596 for (server_id, server_value) in servers {
597 let Some(server_table) = server_value.as_table() else {
598 continue;
599 };
600 let mut merged = self
601 .self_hosted
602 .servers
603 .get(server_id)
604 .cloned()
605 .unwrap_or_default();
606 let Some(server_layer) = layer.servers.get(server_id) else {
607 continue;
608 };
609 if server_table.contains_key("transport") {
610 merged.transport = server_layer.transport;
611 }
612 if server_table.contains_key("base_url") {
613 merged.base_url = server_layer.base_url.clone();
614 }
615 if server_table.contains_key("api_style") {
616 merged.api_style = server_layer.api_style;
617 }
618 merged_servers.insert(server_id.clone(), merged);
619 }
620 self.self_hosted.servers = merged_servers;
621 }
622 }
623
624 if let Some(models) = self_hosted.get("models").and_then(toml::Value::as_table) {
625 if models.is_empty() {
626 self.self_hosted.models.clear();
627 } else {
628 let mut merged_models = self.self_hosted.models.clone();
629 for (model_id, model_value) in models {
630 let Some(model_table) = model_value.as_table() else {
631 continue;
632 };
633 let mut merged = self
634 .self_hosted
635 .models
636 .get(model_id)
637 .cloned()
638 .unwrap_or_default();
639 let Some(model_layer) = layer.models.get(model_id) else {
640 continue;
641 };
642 if model_table.contains_key("server") {
643 merged.server = model_layer.server.clone();
644 }
645 if model_table.contains_key("remote_model") {
646 merged.remote_model = model_layer.remote_model.clone();
647 }
648 if model_table.contains_key("display_name") {
649 merged.display_name = model_layer.display_name.clone();
650 }
651 if model_table.contains_key("family") {
652 merged.family = model_layer.family.clone();
653 }
654 if model_table.contains_key("tier") {
655 merged.tier = model_layer.tier;
656 }
657 if model_table.contains_key("context_window") {
658 merged.context_window = model_layer.context_window;
659 }
660 if model_table.contains_key("max_output_tokens") {
661 merged.max_output_tokens = model_layer.max_output_tokens;
662 }
663 if model_table.contains_key("vision") {
664 merged.vision = model_layer.vision;
665 }
666 if model_table.contains_key("image_tool_results") {
667 merged.image_tool_results = model_layer.image_tool_results;
668 }
669 if model_table.contains_key("inline_video") {
670 merged.inline_video = model_layer.inline_video;
671 }
672 if model_table.contains_key("supports_temperature") {
673 merged.supports_temperature = model_layer.supports_temperature;
674 }
675 if model_table.contains_key("supports_thinking") {
676 merged.supports_thinking = model_layer.supports_thinking;
677 }
678 if model_table.contains_key("supports_reasoning") {
679 merged.supports_reasoning = model_layer.supports_reasoning;
680 }
681 if model_table.contains_key("supports_web_search") {
682 merged.supports_web_search = model_layer.supports_web_search;
683 }
684 if model_table.contains_key("call_timeout_secs") {
685 merged.call_timeout_secs = model_layer.call_timeout_secs;
686 }
687 merged_models.insert(model_id.clone(), merged);
688 }
689 self.self_hosted.models = merged_models;
690 }
691 }
692 }
693
694 fn merge_skills_from_toml_presence(
695 &mut self,
696 parsed: &toml::Value,
697 layer: &crate::skills_config::SkillsConfig,
698 ) {
699 let Some(skills) = parsed.get("skills").and_then(toml::Value::as_table) else {
700 return;
701 };
702 if skills.contains_key("enabled") {
707 self.skills.enabled = layer.enabled;
708 }
709 if skills.contains_key("max_injection_bytes") {
710 self.skills.max_injection_bytes = layer.max_injection_bytes;
711 }
712 if skills.contains_key("inventory_threshold") {
713 self.skills.inventory_threshold = layer.inventory_threshold;
714 }
715 }
716
717 pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
719 self.apply_env_overrides_from(|key| std::env::var(key).ok())
720 }
721
722 #[doc(hidden)]
727 pub fn apply_env_overrides_from<F>(&mut self, _env: F) -> Result<(), ConfigError>
728 where
729 F: FnMut(&str) -> Option<String>,
730 {
731 Ok(())
740 }
741
742 #[cfg(not(target_arch = "wasm32"))]
747 pub fn apply_cli_overrides(&mut self, cli: CliOverrides) {
748 if let Some(model) = cli.model {
749 self.agent.model = model;
750 }
751 if let Some(tokens) = cli.max_tokens {
752 self.budget.max_tokens = Some(tokens);
753 }
754 if let Some(duration) = cli.max_duration {
755 self.budget.max_duration = Some(duration);
756 }
757 if let Some(calls) = cli.max_tool_calls {
758 self.budget.max_tool_calls = Some(calls);
759 }
760 if let Some(delta) = cli.override_config {
762 let mut value = serde_json::to_value(&self).unwrap_or_default();
763 crate::config_store::merge_patch(&mut value, delta.0);
764 if let Ok(updated) = serde_json::from_value(value) {
765 *self = updated;
766 }
767 }
768 }
769}
770
771impl Config {
772 pub fn resolved_max_tokens(&self) -> u32 {
780 self.max_tokens
781 .or_else(|| template_defaults().max_tokens)
782 .filter(|value| *value > 0)
783 .unwrap_or_else(|| self.agent.resolved_max_tokens_per_turn())
784 }
785
786 pub fn validate(&self, catalog: crate::model_profile::ModelCatalog) -> Result<(), ConfigError> {
795 if self.max_tokens == Some(0) {
796 return Err(ConfigError::Validation(
797 "max_tokens must be greater than 0 when set".to_string(),
798 ));
799 }
800 if self.agent.max_tokens_per_turn == Some(0) {
801 return Err(ConfigError::Validation(
802 "agent.max_tokens_per_turn must be greater than 0 when set".to_string(),
803 ));
804 }
805 if self.budget.max_tokens == Some(0) {
806 return Err(ConfigError::Validation(
807 "budget.max_tokens must be greater than 0 when set".to_string(),
808 ));
809 }
810 if self.limits.budget == Some(0) {
811 return Err(ConfigError::Validation(
812 "limits.budget must be greater than 0 when set".to_string(),
813 ));
814 }
815 if self.limits.max_sessions == Some(0) {
816 return Err(ConfigError::Validation(
817 "limits.max_sessions must be greater than 0 when set".to_string(),
818 ));
819 }
820 if self.compaction.auto_compact_threshold == 0 {
821 return Err(ConfigError::Validation(
822 "compaction.auto_compact_threshold must be greater than 0".to_string(),
823 ));
824 }
825 if self.compaction.recent_turn_budget == 0 {
826 return Err(ConfigError::Validation(
827 "compaction.recent_turn_budget must be greater than 0".to_string(),
828 ));
829 }
830 if self.compaction.max_summary_tokens == 0 {
831 return Err(ConfigError::Validation(
832 "compaction.max_summary_tokens must be greater than 0".to_string(),
833 ));
834 }
835 if self.compaction.min_turns_between_compactions == 0 {
836 return Err(ConfigError::Validation(
837 "compaction.min_turns_between_compactions must be greater than 0".to_string(),
838 ));
839 }
840
841 crate::model_registry::ModelRegistry::from_config(self, catalog)?;
848
849 Ok(())
850 }
851}
852
853#[derive(Debug, Clone, Default)]
855pub struct CliOverrides {
856 pub model: Option<String>,
857 pub max_tokens: Option<u64>,
858 pub max_duration: Option<Duration>,
859 pub max_tool_calls: Option<usize>,
860 pub override_config: Option<ConfigDelta>,
862}
863
864pub fn default_structured_output_retries() -> u32 {
871 2
872}
873
874pub const DEFAULT_MAX_TURNS: u32 = 100;
880
881pub const DEFAULT_MAX_TOKENS_PER_TURN: u32 = 16384;
886
887#[derive(Debug, Clone, Serialize, Deserialize)]
889#[serde(default)]
890pub struct AgentConfig {
891 pub system_prompt: Option<String>,
893 pub system_prompt_file: Option<PathBuf>,
895 pub tool_instructions: Option<String>,
897 pub model: String,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub max_tokens_per_turn: Option<u32>,
906 pub temperature: Option<f32>,
908 pub budget_warning_threshold: f32,
910 pub max_turns: Option<u32>,
912 #[serde(
927 default,
928 skip_serializing_if = "crate::lifecycle::run_primitive::ProviderParamsCarrier::serializes_empty"
929 )]
930 pub provider_params: crate::lifecycle::run_primitive::ProviderParamsCarrier,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub output_schema: Option<OutputSchema>,
939 #[serde(default = "default_structured_output_retries")]
941 pub structured_output_retries: u32,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
948 pub extraction_prompt: Option<String>,
949}
950
951impl Default for AgentConfig {
952 fn default() -> Self {
953 let defaults = template_defaults();
954 let agent = defaults.agent.as_ref();
955 Self {
956 system_prompt: None,
957 system_prompt_file: None,
958 tool_instructions: None,
959 model: agent.and_then(|cfg| cfg.model.clone()).unwrap_or_default(),
960 max_tokens_per_turn: None,
964 temperature: None,
965 budget_warning_threshold: agent
966 .and_then(|cfg| cfg.budget_warning_threshold)
967 .unwrap_or_default(),
968 max_turns: None,
969 provider_params: crate::lifecycle::run_primitive::ProviderParamsCarrier::default(),
970 output_schema: None,
971 structured_output_retries: default_structured_output_retries(),
972 extraction_prompt: None,
973 }
974 }
975}
976
977impl AgentConfig {
978 pub fn resolved_max_tokens_per_turn(&self) -> u32 {
983 self.max_tokens_per_turn
984 .or_else(|| {
985 template_defaults()
986 .agent
987 .as_ref()
988 .and_then(|cfg| cfg.max_tokens_per_turn)
989 })
990 .filter(|value| *value > 0)
991 .unwrap_or(DEFAULT_MAX_TOKENS_PER_TURN)
992 }
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1001#[serde(default)]
1002pub struct PresentationConfig {
1003 pub html: HtmlPresentationConfig,
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1011#[serde(default)]
1012pub struct HtmlPresentationConfig {
1013 pub default_template: String,
1014 pub templates: BTreeMap<String, HtmlTemplateConfig>,
1015}
1016
1017impl Default for HtmlPresentationConfig {
1018 fn default() -> Self {
1019 Self {
1020 default_template: "polished".to_string(),
1021 templates: BTreeMap::new(),
1022 }
1023 }
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1028#[serde(default)]
1029pub struct HtmlTemplateConfig {
1030 pub path: Option<PathBuf>,
1031 pub body: Option<String>,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1037#[serde(default)]
1038pub struct ModelDefaults {
1039 pub anthropic: String,
1040 pub openai: String,
1041 pub gemini: String,
1042 #[serde(flatten)]
1049 pub custom: BTreeMap<String, CustomModelConfig>,
1050}
1051
1052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1062#[serde(deny_unknown_fields)]
1063pub struct CustomModelConfig {
1064 pub provider: crate::Provider,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub display_name: Option<String>,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 pub context_window: Option<u32>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub max_output_tokens: Option<u32>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub vision: Option<bool>,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub web_search: Option<bool>,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub call_timeout_secs: Option<u64>,
1086}
1087
1088pub const DEFAULT_SHELL_PROGRAM: &str = "nu";
1090pub const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 30;
1092pub const DEFAULT_SHELL_SECURITY_MODE: SecurityMode = SecurityMode::Unrestricted;
1094
1095#[derive(Debug, Clone, Serialize, PartialEq)]
1097#[serde(default)]
1098pub struct ShellDefaults {
1099 pub program: String,
1100 pub timeout_secs: u64,
1101 pub security_mode: SecurityMode,
1103 pub security_patterns: Vec<String>,
1105}
1106
1107#[derive(Debug, Deserialize, Default)]
1108#[serde(default)]
1109struct ShellDefaultsSeed {
1110 program: Option<String>,
1111 timeout_secs: Option<u64>,
1112 security_mode: Option<SecurityMode>,
1113 security_patterns: Option<Vec<String>>,
1114 #[serde(alias = "allowlist")]
1115 allowlist: Option<Vec<String>>,
1116}
1117
1118impl<'de> Deserialize<'de> for ShellDefaults {
1119 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1120 where
1121 D: Deserializer<'de>,
1122 {
1123 let seed = ShellDefaultsSeed::deserialize(deserializer)?;
1124 let mut defaults = ShellDefaults::default();
1125
1126 if let Some(program) = seed.program {
1127 defaults.program = program;
1128 }
1129 if let Some(timeout_secs) = seed.timeout_secs {
1130 defaults.timeout_secs = timeout_secs;
1131 }
1132 if let Some(security_mode) = seed.security_mode {
1133 defaults.security_mode = security_mode;
1134 }
1135 if let Some(security_patterns) = seed.security_patterns.or(seed.allowlist.clone()) {
1136 defaults.security_patterns = security_patterns;
1137 }
1138
1139 if seed.security_mode.is_none() && seed.allowlist.is_some() {
1140 defaults.security_mode = SecurityMode::AllowList;
1141 }
1142
1143 Ok(defaults)
1144 }
1145}
1146
1147impl Default for ShellDefaults {
1148 fn default() -> Self {
1149 let defaults = template_defaults();
1150 let shell = defaults.shell.as_ref();
1151 Self {
1152 program: shell
1153 .and_then(|cfg| cfg.program.clone())
1154 .unwrap_or_else(|| DEFAULT_SHELL_PROGRAM.to_string()),
1155 timeout_secs: shell
1156 .and_then(|cfg| cfg.timeout_secs)
1157 .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS),
1158 security_mode: shell
1159 .and_then(|cfg| cfg.security_mode)
1160 .unwrap_or(DEFAULT_SHELL_SECURITY_MODE),
1161 security_patterns: shell
1162 .and_then(|cfg| cfg.security_patterns.clone())
1163 .unwrap_or_default(),
1164 }
1165 }
1166}
1167
1168const CONFIG_TEMPLATE_TOML: &str = include_str!("config_template.toml");
1169
1170#[derive(Debug, Deserialize)]
1171struct TemplateAgentDefaults {
1172 model: Option<String>,
1173 max_tokens_per_turn: Option<u32>,
1174 budget_warning_threshold: Option<f32>,
1175}
1176
1177#[derive(Debug, Deserialize)]
1178struct TemplateShellDefaults {
1179 program: Option<String>,
1180 timeout_secs: Option<u64>,
1181 security_mode: Option<SecurityMode>,
1182 security_patterns: Option<Vec<String>>,
1183}
1184
1185#[derive(Debug, Deserialize)]
1186struct TemplateDefaults {
1187 agent: Option<TemplateAgentDefaults>,
1188 shell: Option<TemplateShellDefaults>,
1189 max_tokens: Option<u32>,
1190}
1191
1192impl TemplateDefaults {
1193 fn empty() -> Self {
1194 Self {
1195 agent: None,
1196 shell: None,
1197 max_tokens: None,
1198 }
1199 }
1200}
1201
1202fn template_defaults() -> &'static TemplateDefaults {
1203 static DEFAULTS: OnceLock<TemplateDefaults> = OnceLock::new();
1204 DEFAULTS.get_or_init(|| {
1205 toml::from_str(CONFIG_TEMPLATE_TOML).unwrap_or_else(|e| {
1206 tracing::error!("Invalid config template defaults: {}", e);
1209 TemplateDefaults::empty()
1210 })
1211 })
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1216#[serde(default)]
1217pub struct StoreConfig {
1218 pub sessions_path: Option<PathBuf>,
1219 pub tasks_path: Option<PathBuf>,
1220 pub database_dir: Option<PathBuf>,
1222}
1223
1224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1230#[serde(rename_all = "snake_case")]
1231pub enum SelfHostedTransport {
1232 #[default]
1233 #[serde(alias = "openai_compatible")]
1234 OpenAiCompatible,
1235}
1236
1237#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1238#[serde(rename_all = "snake_case")]
1239pub enum SelfHostedApiStyle {
1240 Responses,
1241 #[default]
1242 ChatCompletions,
1243}
1244
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1254#[serde(default, deny_unknown_fields)]
1255pub struct SelfHostedServerConfig {
1256 pub transport: SelfHostedTransport,
1257 pub base_url: String,
1258 pub api_style: SelfHostedApiStyle,
1259}
1260
1261impl Default for SelfHostedServerConfig {
1262 fn default() -> Self {
1263 Self {
1264 transport: SelfHostedTransport::OpenAiCompatible,
1265 base_url: String::new(),
1266 api_style: SelfHostedApiStyle::ChatCompletions,
1267 }
1268 }
1269}
1270
1271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1272#[serde(default)]
1273pub struct SelfHostedModelConfig {
1274 pub server: String,
1275 pub remote_model: String,
1276 pub display_name: String,
1277 pub family: String,
1278 pub tier: ModelTier,
1279 #[serde(default, skip_serializing_if = "Option::is_none")]
1280 pub context_window: Option<u32>,
1281 #[serde(default, skip_serializing_if = "Option::is_none")]
1282 pub max_output_tokens: Option<u32>,
1283 pub vision: bool,
1284 pub image_tool_results: bool,
1285 pub inline_video: bool,
1286 pub supports_temperature: bool,
1287 pub supports_thinking: bool,
1288 pub supports_reasoning: bool,
1289 #[serde(default)]
1291 pub supports_web_search: bool,
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub call_timeout_secs: Option<u64>,
1294}
1295
1296impl Default for SelfHostedModelConfig {
1297 fn default() -> Self {
1298 Self {
1299 server: String::new(),
1300 remote_model: String::new(),
1301 display_name: String::new(),
1302 family: String::new(),
1303 tier: ModelTier::Supported,
1304 context_window: None,
1305 max_output_tokens: None,
1306 vision: false,
1307 image_tool_results: false,
1308 inline_video: false,
1309 supports_temperature: true,
1310 supports_thinking: false,
1311 supports_reasoning: false,
1312 supports_web_search: false,
1313 call_timeout_secs: None,
1314 }
1315 }
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1319#[serde(default)]
1320pub struct SelfHostedConfig {
1321 pub servers: BTreeMap<String, SelfHostedServerConfig>,
1322 pub models: BTreeMap<String, SelfHostedModelConfig>,
1323 #[serde(skip_serializing_if = "Option::is_none")]
1332 pub default_model: Option<String>,
1333}
1334
1335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1347#[serde(default)]
1348pub struct ProviderToolsConfig {
1349 pub anthropic: AnthropicProviderToolsConfig,
1350 pub openai: OpenAiProviderToolsConfig,
1351 pub gemini: GeminiProviderToolsConfig,
1352}
1353
1354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1361#[serde(default)]
1362pub struct ModelFallbackConfig {
1363 pub enabled: bool,
1365 #[serde(default, skip_serializing_if = "is_false")]
1369 pub use_catalog_default_chain: bool,
1370 #[serde(skip_serializing_if = "Vec::is_empty")]
1372 pub chain: Vec<ModelFallbackTarget>,
1373}
1374
1375impl Default for ModelFallbackConfig {
1376 fn default() -> Self {
1377 Self {
1378 enabled: true,
1379 use_catalog_default_chain: false,
1380 chain: Vec::new(),
1381 }
1382 }
1383}
1384
1385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1387#[serde(deny_unknown_fields)]
1388pub struct ModelFallbackTarget {
1389 pub model: String,
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1394 pub provider: Option<crate::Provider>,
1395 #[serde(default, skip_serializing_if = "Option::is_none")]
1399 pub auth_binding: Option<crate::AuthBindingRef>,
1400}
1401
1402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1404#[serde(default)]
1405pub struct AnthropicProviderToolsConfig {
1406 pub web_search: bool,
1408}
1409
1410impl Default for AnthropicProviderToolsConfig {
1411 fn default() -> Self {
1412 Self { web_search: true }
1413 }
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1418#[serde(default)]
1419pub struct OpenAiProviderToolsConfig {
1420 pub web_search: bool,
1422}
1423
1424impl Default for OpenAiProviderToolsConfig {
1425 fn default() -> Self {
1426 Self { web_search: true }
1427 }
1428}
1429
1430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1432#[serde(default)]
1433pub struct GeminiProviderToolsConfig {
1434 pub google_search: bool,
1436}
1437
1438impl Default for GeminiProviderToolsConfig {
1439 fn default() -> Self {
1440 Self {
1441 google_search: true,
1442 }
1443 }
1444}
1445
1446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1448#[serde(default)]
1449pub struct LimitsConfig {
1450 pub budget: Option<u64>,
1451 pub max_sessions: Option<usize>,
1455 #[serde(with = "optional_duration_serde")]
1456 pub max_duration: Option<Duration>,
1457}
1458
1459impl LimitsConfig {
1460 pub fn to_budget_limits(&self) -> BudgetLimits {
1461 BudgetLimits {
1462 max_tokens: self.budget,
1463 max_duration: self.max_duration,
1464 max_tool_calls: None,
1465 }
1466 }
1467}
1468
1469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1471#[serde(default)]
1472pub struct RestServerConfig {
1473 pub host: String,
1474 pub port: u16,
1475}
1476
1477impl Default for RestServerConfig {
1478 fn default() -> Self {
1479 Self {
1480 host: "127.0.0.1".to_string(),
1481 port: 8080,
1482 }
1483 }
1484}
1485
1486#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
1496#[serde(rename_all = "snake_case")]
1497pub enum CommsAuthMode {
1498 #[default]
1499 #[serde(rename = "none")]
1500 Open,
1501 Ed25519,
1502}
1503
1504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1509#[serde(rename_all = "snake_case")]
1510pub enum PlainEventSource {
1511 Tcp,
1512 Uds,
1513 Stdin,
1514 Webhook,
1515 Rpc,
1516}
1517
1518impl std::fmt::Display for PlainEventSource {
1519 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1520 match self {
1521 Self::Tcp => write!(f, "tcp"),
1522 Self::Uds => write!(f, "uds"),
1523 Self::Stdin => write!(f, "stdin"),
1524 Self::Webhook => write!(f, "webhook"),
1525 Self::Rpc => write!(f, "rpc"),
1526 }
1527 }
1528}
1529
1530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1532#[serde(default)]
1533pub struct CommsRuntimeConfig {
1534 pub mode: CommsRuntimeMode,
1535 pub address: Option<String>,
1537 pub advertise_address: Option<String>,
1539 pub auth: CommsAuthMode,
1540 pub require_peer_auth: bool,
1546 pub event_address: Option<String>,
1549 #[serde(skip)]
1554 pub pairing_password: Option<String>,
1555}
1556
1557impl Default for CommsRuntimeConfig {
1558 fn default() -> Self {
1559 Self {
1560 mode: CommsRuntimeMode::Inproc,
1561 address: None,
1562 advertise_address: None,
1563 auth: CommsAuthMode::default(),
1564 require_peer_auth: true,
1565 event_address: None,
1566 pairing_password: None,
1567 }
1568 }
1569}
1570
1571#[derive(Debug, Clone, PartialEq)]
1576pub struct CompactionRuntimeConfig {
1577 pub auto_compact_threshold: u64,
1579 pub auto_compact_threshold_explicit: bool,
1584 pub recent_turn_budget: usize,
1586 pub max_summary_tokens: u32,
1588 pub min_turns_between_compactions: u32,
1590}
1591
1592impl Default for CompactionRuntimeConfig {
1593 fn default() -> Self {
1594 Self {
1595 auto_compact_threshold: 100_000,
1596 auto_compact_threshold_explicit: false,
1597 recent_turn_budget: 4,
1598 max_summary_tokens: 4096,
1599 min_turns_between_compactions: 3,
1600 }
1601 }
1602}
1603
1604impl Serialize for CompactionRuntimeConfig {
1605 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1606 where
1607 S: serde::Serializer,
1608 {
1609 let defaults = Self::default();
1610 let include_threshold = self.auto_compact_threshold_explicit
1611 || self.auto_compact_threshold != defaults.auto_compact_threshold;
1612 let mut len = 3;
1613 if include_threshold {
1614 len += 1;
1615 }
1616
1617 let mut state = serializer.serialize_struct("CompactionRuntimeConfig", len)?;
1618 if include_threshold {
1619 state.serialize_field("auto_compact_threshold", &self.auto_compact_threshold)?;
1620 }
1621 state.serialize_field("recent_turn_budget", &self.recent_turn_budget)?;
1622 state.serialize_field("max_summary_tokens", &self.max_summary_tokens)?;
1623 state.serialize_field(
1624 "min_turns_between_compactions",
1625 &self.min_turns_between_compactions,
1626 )?;
1627 state.end()
1628 }
1629}
1630
1631impl<'de> Deserialize<'de> for CompactionRuntimeConfig {
1632 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1633 where
1634 D: Deserializer<'de>,
1635 {
1636 #[derive(Deserialize)]
1637 struct Seed {
1638 auto_compact_threshold: Option<u64>,
1639 recent_turn_budget: Option<usize>,
1640 max_summary_tokens: Option<u32>,
1641 min_turns_between_compactions: Option<u32>,
1642 }
1643
1644 let seed = Seed::deserialize(deserializer)?;
1645 let defaults = Self::default();
1646 Ok(Self {
1647 auto_compact_threshold: seed
1648 .auto_compact_threshold
1649 .unwrap_or(defaults.auto_compact_threshold),
1650 auto_compact_threshold_explicit: seed.auto_compact_threshold.is_some(),
1651 recent_turn_budget: seed
1652 .recent_turn_budget
1653 .unwrap_or(defaults.recent_turn_budget),
1654 max_summary_tokens: seed
1655 .max_summary_tokens
1656 .unwrap_or(defaults.max_summary_tokens),
1657 min_turns_between_compactions: seed
1658 .min_turns_between_compactions
1659 .unwrap_or(defaults.min_turns_between_compactions),
1660 })
1661 }
1662}
1663
1664impl From<CompactionRuntimeConfig> for crate::CompactionConfig {
1665 fn from(value: CompactionRuntimeConfig) -> Self {
1666 Self {
1667 auto_compact_threshold: value.auto_compact_threshold,
1668 recent_turn_budget: value.recent_turn_budget,
1669 max_summary_tokens: value.max_summary_tokens,
1670 min_turns_between_compactions: value.min_turns_between_compactions,
1671 }
1672 }
1673}
1674
1675#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
1677#[serde(rename_all = "snake_case")]
1678pub enum CommsRuntimeMode {
1679 #[default]
1680 Inproc,
1681 Tcp,
1682 Uds,
1683}
1684
1685#[derive(Debug, Clone, Serialize, Deserialize)]
1699#[serde(default)]
1700pub struct StorageConfig {
1701 pub directory: Option<PathBuf>,
1703}
1704
1705impl Default for StorageConfig {
1706 fn default() -> Self {
1707 Self {
1708 directory: data_dir().map(|d| d.join("sessions")),
1709 }
1710 }
1711}
1712
1713#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1715#[serde(default)]
1716pub struct BudgetConfig {
1717 pub max_tokens: Option<u64>,
1719 #[serde(with = "optional_duration_serde")]
1721 pub max_duration: Option<Duration>,
1722 pub max_tool_calls: Option<usize>,
1724}
1725
1726#[derive(Debug, Clone, Default, PartialEq, Eq)]
1738#[non_exhaustive]
1739pub enum CallTimeoutOverride {
1740 #[default]
1742 Inherit,
1743 Disabled,
1745 Value(Duration),
1747}
1748
1749impl Serialize for CallTimeoutOverride {
1750 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1751 match self {
1752 Self::Inherit => serializer.serialize_none(),
1754 Self::Disabled => serializer.serialize_str("disabled"),
1755 Self::Value(d) => {
1756 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1757 serializer.serialize_str(&s)
1758 }
1759 }
1760 }
1761}
1762
1763impl<'de> Deserialize<'de> for CallTimeoutOverride {
1764 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1765 let s = String::deserialize(deserializer)?;
1766 if s == "disabled" {
1767 return Ok(Self::Disabled);
1768 }
1769 let d: Duration = s
1770 .parse::<humantime_serde::re::humantime::Duration>()
1771 .map(|ht| *ht)
1772 .map_err(serde::de::Error::custom)?;
1773 Ok(Self::Value(d))
1774 }
1775}
1776
1777impl CallTimeoutOverride {
1778 pub fn is_inherit(&self) -> bool {
1780 matches!(self, Self::Inherit)
1781 }
1782}
1783
1784#[derive(Debug, Clone, Default, PartialEq, Eq)]
1804pub enum SystemPromptOverride {
1805 #[default]
1808 Inherit,
1809 Set(String),
1812 Disable,
1816}
1817
1818impl SystemPromptOverride {
1819 #[must_use]
1822 pub fn is_inherit(&self) -> bool {
1823 matches!(self, Self::Inherit)
1824 }
1825
1826 #[must_use]
1830 pub fn is_explicit(&self) -> bool {
1831 !matches!(self, Self::Inherit)
1832 }
1833
1834 #[must_use]
1836 pub fn as_set_prompt(&self) -> Option<&str> {
1837 match self {
1838 Self::Set(prompt) => Some(prompt.as_str()),
1839 Self::Inherit | Self::Disable => None,
1840 }
1841 }
1842}
1843
1844const SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION: &str = "disable";
1845
1846impl Serialize for SystemPromptOverride {
1847 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1848 match self {
1849 Self::Inherit => serializer.serialize_none(),
1853 Self::Set(prompt) => serializer.serialize_str(prompt),
1854 Self::Disable => {
1855 use serde::ser::SerializeMap;
1856 let mut map = serializer.serialize_map(Some(1))?;
1857 map.serialize_entry("action", SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION)?;
1858 map.end()
1859 }
1860 }
1861 }
1862}
1863
1864impl<'de> Deserialize<'de> for SystemPromptOverride {
1865 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1866 struct SystemPromptOverrideVisitor;
1867
1868 impl<'de> serde::de::Visitor<'de> for SystemPromptOverrideVisitor {
1869 type Value = SystemPromptOverride;
1870
1871 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1872 formatter.write_str(
1873 "a system prompt string, null (inherit), or {\"action\": \"disable\"}",
1874 )
1875 }
1876
1877 fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
1878 Ok(SystemPromptOverride::Set(value.to_owned()))
1879 }
1880
1881 fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
1882 Ok(SystemPromptOverride::Set(value))
1883 }
1884
1885 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1886 Ok(SystemPromptOverride::Inherit)
1887 }
1888
1889 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1890 Ok(SystemPromptOverride::Inherit)
1891 }
1892
1893 fn visit_some<D2: Deserializer<'de>>(
1894 self,
1895 deserializer: D2,
1896 ) -> Result<Self::Value, D2::Error> {
1897 deserializer.deserialize_any(SystemPromptOverrideVisitor)
1898 }
1899
1900 fn visit_map<A: serde::de::MapAccess<'de>>(
1901 self,
1902 mut map: A,
1903 ) -> Result<Self::Value, A::Error> {
1904 let mut action: Option<String> = None;
1905 while let Some(key) = map.next_key::<String>()? {
1906 match key.as_str() {
1907 "action" => {
1908 if action.is_some() {
1909 return Err(serde::de::Error::duplicate_field("action"));
1910 }
1911 action = Some(map.next_value()?);
1912 }
1913 other => {
1914 return Err(serde::de::Error::unknown_field(other, &["action"]));
1915 }
1916 }
1917 }
1918 let action = action.ok_or_else(|| serde::de::Error::missing_field("action"))?;
1919 if action == SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION {
1920 Ok(SystemPromptOverride::Disable)
1921 } else {
1922 Err(serde::de::Error::custom(format!(
1923 "unknown system_prompt override action '{action}' (expected \"disable\")"
1924 )))
1925 }
1926 }
1927 }
1928
1929 deserializer.deserialize_any(SystemPromptOverrideVisitor)
1930 }
1931}
1932
1933impl JsonSchema for SystemPromptOverride {
1934 fn schema_name() -> std::borrow::Cow<'static, str> {
1935 "SystemPromptOverride".into()
1936 }
1937
1938 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1939 schemars::json_schema!({
1940 "description": "Per-request system-prompt policy: omit/null to inherit, a string to set an explicit prompt, or {\"action\": \"disable\"} to suppress every prompt source.",
1941 "anyOf": [
1942 { "type": "null", "description": "Inherit the configured/default prompt sources." },
1943 { "type": "string", "description": "Explicit per-request system prompt." },
1944 {
1945 "type": "object",
1946 "properties": { "action": { "const": "disable" } },
1947 "required": ["action"],
1948 "additionalProperties": false,
1949 "description": "Suppress every prompt source."
1950 }
1951 ]
1952 })
1953 }
1954}
1955
1956#[derive(Debug, Clone, Serialize, Deserialize)]
1958#[serde(default)]
1959pub struct RetryConfig {
1960 pub max_retries: u32,
1962 #[serde(with = "humantime_serde")]
1964 pub initial_delay: Duration,
1965 #[serde(with = "humantime_serde")]
1967 pub max_delay: Duration,
1968 pub multiplier: f64,
1970 #[serde(
1976 default,
1977 rename = "call_timeout",
1978 skip_serializing_if = "CallTimeoutOverride::is_inherit"
1979 )]
1980 pub call_timeout_override: CallTimeoutOverride,
1981}
1982
1983impl Default for RetryConfig {
1984 fn default() -> Self {
1985 let policy = RetryPolicy::default();
1986 Self {
1987 max_retries: policy.max_retries,
1988 initial_delay: policy.initial_delay,
1989 max_delay: policy.max_delay,
1990 multiplier: policy.multiplier,
1991 call_timeout_override: CallTimeoutOverride::default(),
1992 }
1993 }
1994}
1995
1996impl From<RetryConfig> for RetryPolicy {
1997 fn from(config: RetryConfig) -> Self {
1998 let call_timeout = match config.call_timeout_override {
2001 CallTimeoutOverride::Inherit => None,
2002 CallTimeoutOverride::Disabled => None,
2003 CallTimeoutOverride::Value(d) => Some(d),
2004 };
2005 RetryPolicy {
2006 max_retries: config.max_retries,
2007 initial_delay: config.initial_delay,
2008 max_delay: config.max_delay,
2009 multiplier: config.multiplier,
2010 call_timeout,
2011 }
2012 }
2013}
2014
2015#[derive(Debug, Clone, Serialize, Deserialize)]
2017#[serde(default)]
2018pub struct ToolsConfig {
2019 #[serde(default)]
2021 pub mcp_servers: Vec<McpServerConfig>,
2022 #[serde(with = "humantime_serde")]
2024 pub default_timeout: Duration,
2025 #[serde(default)]
2027 pub tool_timeouts: HashMap<String, Duration>,
2028 pub max_concurrent: usize,
2030 pub builtins_enabled: bool,
2032 pub shell_enabled: bool,
2034 pub comms_enabled: bool,
2036 pub mob_enabled: bool,
2038 pub schedule_enabled: bool,
2040 pub workgraph_enabled: bool,
2042}
2043
2044impl Default for ToolsConfig {
2045 fn default() -> Self {
2046 Self {
2047 mcp_servers: Vec::new(),
2048 default_timeout: Duration::from_secs(600),
2049 tool_timeouts: HashMap::new(),
2050 max_concurrent: 10,
2051 builtins_enabled: false,
2052 shell_enabled: false,
2053 comms_enabled: false,
2054 mob_enabled: false,
2055 schedule_enabled: true,
2056 workgraph_enabled: false,
2057 }
2058 }
2059}
2060
2061#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
2063#[serde(default)]
2064pub struct HooksConfig {
2065 pub default_timeout_ms: u64,
2067 pub payload_max_bytes: usize,
2069 pub background_max_concurrency: usize,
2071 #[serde(default)]
2073 pub entries: Vec<HookEntryConfig>,
2074}
2075
2076impl HooksConfig {
2077 pub fn append_entries_from(&mut self, other: &HooksConfig) {
2078 self.entries.extend(other.entries.clone());
2079 }
2080}
2081
2082impl Default for HooksConfig {
2083 fn default() -> Self {
2084 Self {
2085 default_timeout_ms: 5_000,
2086 payload_max_bytes: 128 * 1024,
2087 background_max_concurrency: 32,
2088 entries: Vec::new(),
2089 }
2090 }
2091}
2092
2093#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
2095#[serde(default)]
2096pub struct HookRunOverrides {
2097 #[serde(default)]
2099 pub entries: Vec<HookEntryConfig>,
2100 #[serde(default)]
2102 pub disable: Vec<HookId>,
2103}
2104
2105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
2107#[serde(default)]
2108pub struct HookEntryConfig {
2109 pub id: HookId,
2110 pub enabled: bool,
2111 pub point: HookPoint,
2112 pub mode: HookExecutionMode,
2113 pub capability: HookCapability,
2114 pub priority: i32,
2115 #[serde(default, skip_serializing_if = "Option::is_none")]
2116 pub timeout_ms: Option<u64>,
2117 pub runtime: HookAdapterConfig,
2118}
2119
2120impl Default for HookEntryConfig {
2121 fn default() -> Self {
2122 Self {
2123 id: HookId::new("hook"),
2124 enabled: true,
2125 point: HookPoint::TurnBoundary,
2126 mode: HookExecutionMode::Foreground,
2127 capability: HookCapability::Observe,
2128 priority: 100,
2129 timeout_ms: None,
2130 runtime: HookAdapterConfig::in_process("noop"),
2131 }
2132 }
2133}
2134
2135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
2137#[serde(transparent)]
2138pub struct HookInProcessHandlerId(String);
2139
2140impl HookInProcessHandlerId {
2141 pub fn new(value: impl Into<String>) -> Self {
2142 Self(value.into())
2143 }
2144
2145 pub fn as_str(&self) -> &str {
2146 &self.0
2147 }
2148}
2149
2150impl std::fmt::Display for HookInProcessHandlerId {
2151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2152 self.0.fmt(f)
2153 }
2154}
2155
2156impl From<&str> for HookInProcessHandlerId {
2157 fn from(value: &str) -> Self {
2158 Self::new(value)
2159 }
2160}
2161
2162impl From<String> for HookInProcessHandlerId {
2163 fn from(value: String) -> Self {
2164 Self::new(value)
2165 }
2166}
2167
2168impl Default for HookInProcessHandlerId {
2169 fn default() -> Self {
2170 Self::new("noop")
2171 }
2172}
2173
2174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2179#[serde(default)]
2180pub struct HookInProcessRuntimeConfig {
2181 #[serde(alias = "name")]
2182 pub handler: HookInProcessHandlerId,
2183}
2184
2185impl HookInProcessRuntimeConfig {
2186 pub fn new(handler: impl Into<HookInProcessHandlerId>) -> Self {
2187 Self {
2188 handler: handler.into(),
2189 }
2190 }
2191}
2192
2193impl Default for HookInProcessRuntimeConfig {
2194 fn default() -> Self {
2195 Self::new("noop")
2196 }
2197}
2198
2199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2206pub enum HookRuntimeKind {
2207 InProcess,
2208 Command,
2209 Http,
2210}
2211
2212impl HookRuntimeKind {
2213 pub fn as_str(&self) -> &'static str {
2215 match self {
2216 Self::InProcess => "in_process",
2217 Self::Command => "command",
2218 Self::Http => "http",
2219 }
2220 }
2221
2222 pub fn parse(s: &str) -> Option<Self> {
2224 match s {
2225 "in_process" => Some(Self::InProcess),
2226 "command" => Some(Self::Command),
2227 "http" => Some(Self::Http),
2228 _ => None,
2229 }
2230 }
2231}
2232
2233impl std::fmt::Display for HookRuntimeKind {
2234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2235 f.write_str(self.as_str())
2236 }
2237}
2238
2239#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2241pub struct CommandRuntimeConfig {
2242 pub command: String,
2243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2244 pub args: Vec<String>,
2245 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2246 pub env: HashMap<String, String>,
2247}
2248
2249fn default_http_method() -> String {
2250 "POST".to_string()
2251}
2252
2253fn is_false(value: &bool) -> bool {
2254 !*value
2255}
2256
2257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2259pub struct HttpRuntimeConfig {
2260 pub url: String,
2261 #[serde(default = "default_http_method")]
2262 pub method: String,
2263 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2264 pub headers: HashMap<String, String>,
2265}
2266
2267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2279#[serde(tag = "type", rename_all = "snake_case")]
2280pub enum HookAdapterConfig {
2281 InProcess(HookInProcessRuntimeConfig),
2282 Command(CommandRuntimeConfig),
2283 Http(HttpRuntimeConfig),
2284}
2285
2286impl HookAdapterConfig {
2287 pub fn in_process(handler: impl Into<HookInProcessHandlerId>) -> Self {
2289 Self::InProcess(HookInProcessRuntimeConfig::new(handler))
2290 }
2291
2292 pub fn command(
2294 command: impl Into<String>,
2295 args: Vec<String>,
2296 env: HashMap<String, String>,
2297 ) -> Self {
2298 Self::Command(CommandRuntimeConfig {
2299 command: command.into(),
2300 args,
2301 env,
2302 })
2303 }
2304
2305 pub fn http(
2307 url: impl Into<String>,
2308 method: impl Into<String>,
2309 headers: HashMap<String, String>,
2310 ) -> Self {
2311 Self::Http(HttpRuntimeConfig {
2312 url: url.into(),
2313 method: method.into(),
2314 headers,
2315 })
2316 }
2317
2318 pub fn from_kind_and_value(
2326 kind: HookRuntimeKind,
2327 config: Option<Value>,
2328 ) -> Result<Self, serde_json::Error> {
2329 let mut obj = match config {
2330 Some(Value::Object(obj)) => obj,
2331 Some(Value::Null) | None => Map::new(),
2332 Some(other) => {
2333 let mut obj = Map::new();
2334 obj.insert("config".to_string(), other);
2335 obj
2336 }
2337 };
2338 obj.insert("type".to_string(), Value::String(kind.as_str().to_string()));
2339 serde_json::from_value(Value::Object(obj))
2340 }
2341
2342 pub fn kind(&self) -> HookRuntimeKind {
2344 match self {
2345 Self::InProcess(_) => HookRuntimeKind::InProcess,
2346 Self::Command(_) => HookRuntimeKind::Command,
2347 Self::Http(_) => HookRuntimeKind::Http,
2348 }
2349 }
2350}
2351
2352impl Default for HookAdapterConfig {
2353 fn default() -> Self {
2354 Self::in_process("noop")
2355 }
2356}
2357
2358#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2360#[serde(rename_all = "snake_case")]
2361pub enum ConfigScope {
2362 Global,
2363 Project,
2364}
2365
2366#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2368#[serde(transparent)]
2369pub struct ConfigDelta(pub serde_json::Value);
2370
2371#[derive(Debug, thiserror::Error)]
2373pub enum ConfigError {
2374 #[error("IO error: {0}")]
2375 Io(#[from] std::io::Error),
2376
2377 #[error("Parse error: {0}")]
2378 Parse(#[from] toml::de::Error),
2379
2380 #[error("TOML serialization error: {0}")]
2381 TomlSerialize(#[from] toml::ser::Error),
2382
2383 #[error("JSON error: {0}")]
2384 Json(#[from] serde_json::Error),
2385
2386 #[error("UTF-8 error: {0}")]
2387 Utf8(#[from] std::string::FromUtf8Error),
2388
2389 #[allow(dead_code)]
2390 #[error("Invalid value for {0}")]
2391 InvalidValue(String),
2392
2393 #[error("Missing required field: {0}")]
2394 MissingField(String),
2395
2396 #[error("Internal error: {0}")]
2397 InternalError(String),
2398
2399 #[error("Validation error: {0}")]
2400 Validation(String),
2401
2402 #[error("realm inheritance chain error: {0}")]
2403 RealmChain(#[from] crate::connection::RealmChainError),
2404}
2405
2406pub fn compose_effective_config(
2417 docs: &std::collections::BTreeMap<crate::connection::RealmId, Config>,
2418 raw_docs: &std::collections::BTreeMap<crate::connection::RealmId, toml::Value>,
2419 head: &crate::connection::RealmId,
2420) -> Result<Config, crate::connection::RealmChainError> {
2421 use crate::connection::RealmChain;
2422 let mut topology = Config::default();
2426 for doc in docs.values() {
2427 for (realm_id, section) in &doc.realm {
2428 topology.realm.insert(realm_id.clone(), section.clone());
2429 }
2430 }
2431 let chain = RealmChain::resolve(&topology, head)?;
2432 let mut effective = Config::default();
2435 let default_self_hosted = SelfHostedConfig::default();
2436 let default_provider_tools = ProviderToolsConfig::default();
2437 for member in chain.realms().iter().rev() {
2438 if let Some(doc) = docs.get(member) {
2439 effective.merge(doc.clone());
2440 if let Some(raw) = raw_docs.get(member) {
2458 effective.merge_tools_from_toml_presence(raw, &doc.tools);
2459 effective.merge_retry_from_toml_presence(raw, &doc.retry);
2460 effective.merge_provider_tools_from_toml_presence(raw, &doc.provider_tools);
2461 effective.merge_self_hosted_from_toml_presence(raw, &doc.self_hosted);
2462 effective.merge_skills_from_toml_presence(raw, &doc.skills);
2463 } else {
2464 if doc.self_hosted != default_self_hosted {
2468 effective.self_hosted = doc.self_hosted.clone();
2469 }
2470 if doc.provider_tools != default_provider_tools {
2471 effective.provider_tools = doc.provider_tools.clone();
2472 }
2473 }
2474 }
2475 }
2476 Ok(effective)
2487}
2488
2489mod optional_duration_serde {
2491 use serde::{Deserialize, Deserializer, Serialize, Serializer};
2492 use std::time::Duration;
2493
2494 #[allow(clippy::ref_option)]
2497 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
2498 where
2499 S: Serializer,
2500 {
2501 match duration {
2502 Some(d) => {
2503 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
2504 s.serialize(serializer)
2505 }
2506 None => serializer.serialize_none(),
2507 }
2508 }
2509
2510 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
2511 where
2512 D: Deserializer<'de>,
2513 {
2514 use serde::de::Error;
2515
2516 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
2518 match value {
2519 None => Ok(None),
2520 Some(serde_json::Value::String(s)) => {
2521 humantime_serde::re::humantime::parse_duration(&s)
2522 .map(Some)
2523 .map_err(|e| D::Error::custom(e.to_string()))
2524 }
2525 Some(serde_json::Value::Number(n)) => {
2526 let millis = n
2528 .as_u64()
2529 .ok_or_else(|| D::Error::custom("invalid number"))?;
2530 Ok(Some(Duration::from_millis(millis)))
2531 }
2532 _ => Err(D::Error::custom("expected string or number for duration")),
2533 }
2534 }
2535}
2536
2537pub fn find_project_root(start_dir: &std::path::Path) -> Option<PathBuf> {
2539 let mut current = start_dir.to_path_buf();
2540 loop {
2541 if current.join(".rkat").is_dir() {
2542 return Some(current);
2543 }
2544 if !current.pop() {
2545 return None;
2546 }
2547 }
2548}
2549
2550pub fn data_dir() -> Option<PathBuf> {
2556 if let Ok(cwd) = std::env::current_dir()
2558 && let Some(root) = find_project_root(&cwd)
2559 {
2560 return Some(root.join(".rkat"));
2561 }
2562
2563 dirs::home_dir().map(|h| h.join(".rkat"))
2565}
2566
2567pub mod dirs {
2569 use std::path::PathBuf;
2570
2571 pub fn home_dir() -> Option<PathBuf> {
2572 std::env::var_os("HOME").map(PathBuf::from)
2573 }
2574}
2575
2576#[cfg(test)]
2577#[allow(clippy::unwrap_used, clippy::expect_used)]
2578mod tests {
2579 use super::*;
2580 use crate::Provider;
2581
2582 #[test]
2583 fn test_config_default() {
2584 let config = Config::default();
2585 assert!(
2586 config.agent.model.is_empty(),
2587 "core embeds no provider data: the default agent model is empty \
2588 and resolves through the injected catalog at build time"
2589 );
2590 assert_eq!(config.agent.max_tokens_per_turn, None);
2593 assert_eq!(config.agent.resolved_max_tokens_per_turn(), 16384);
2594 assert_eq!(config.retry.max_retries, 3);
2595 assert_eq!(config.max_sessions(), DEFAULT_MAX_SESSIONS);
2596 }
2597
2598 #[test]
2599 fn config_template_pins_no_model() {
2600 let config = Config::template().expect("template parses");
2601 assert!(
2602 config.agent.model.is_empty(),
2603 "the core config template must not pin a provider model; defaults \
2604 come from the injected catalog (meerkat-models)"
2605 );
2606 assert!(config.models.anthropic.is_empty());
2607 assert!(config.models.openai.is_empty());
2608 assert!(config.models.gemini.is_empty());
2609 }
2610
2611 #[test]
2612 fn test_limits_max_sessions_configures_runtime_capacity() {
2613 let mut config = Config::default();
2614 config
2615 .merge_toml_str(
2616 r"
2617[limits]
2618max_sessions = 7
2619",
2620 )
2621 .expect("merge max_sessions");
2622 assert_eq!(config.max_sessions(), 7);
2623 }
2624
2625 #[test]
2626 fn test_config_layering() {
2627 let config = Config::default();
2629 assert!(config.agent.model.is_empty());
2630 assert_eq!(config.budget.max_tokens, None);
2631
2632 {
2634 let env = std::collections::HashMap::from([(
2640 "RKAT_MODEL".to_string(),
2641 "env-model".to_string(),
2642 )]);
2643 let mut config = Config::default();
2644 config
2645 .apply_env_overrides_from(|key| env.get(key).cloned())
2646 .expect("apply env overrides");
2647 }
2648
2649 let mut config = Config::default();
2651 let file_config = Config {
2652 agent: AgentConfig {
2653 model: "file-model".to_string(),
2654 ..Default::default()
2655 },
2656 ..Default::default()
2657 };
2658 config.merge(file_config);
2659 assert_eq!(config.agent.model, "file-model");
2660
2661 let mut config = Config::default();
2663 config.apply_cli_overrides(CliOverrides {
2664 model: Some("cli-model".to_string()),
2665 max_tokens: Some(50000),
2666 ..Default::default()
2667 });
2668 assert_eq!(config.agent.model, "cli-model");
2670 assert_eq!(config.budget.max_tokens, Some(50000));
2671 }
2672
2673 #[test]
2674 fn test_merge_extraction_prompt_survives_layering() {
2675 let mut base = Config::default();
2676 assert!(base.agent.extraction_prompt.is_none());
2677
2678 let toml = r#"
2680[agent]
2681extraction_prompt = "Return JSON only."
2682"#;
2683 base.merge_toml_str(toml).expect("merge toml");
2684 assert_eq!(
2685 base.agent.extraction_prompt.as_deref(),
2686 Some("Return JSON only.")
2687 );
2688
2689 let toml2 = r#"
2691[agent]
2692model = "custom-model"
2693"#;
2694 base.merge_toml_str(toml2).expect("merge toml2");
2695 assert_eq!(
2696 base.agent.extraction_prompt.as_deref(),
2697 Some("Return JSON only."),
2698 "extraction_prompt must survive merge when absent in later layer"
2699 );
2700 assert_eq!(base.agent.model, "custom-model");
2701 }
2702
2703 #[test]
2704 fn test_merge_hooks_entries_append() {
2705 let mut base = Config::default();
2706 let base_entry = HookEntryConfig {
2707 id: HookId::new("base"),
2708 ..HookEntryConfig::default()
2709 };
2710 base.hooks.entries.push(base_entry);
2711
2712 let mut other = Config::default();
2713 let other_entry = HookEntryConfig {
2714 id: HookId::new("other"),
2715 ..HookEntryConfig::default()
2716 };
2717 other.hooks.entries.push(other_entry);
2718
2719 base.merge(other);
2720 let ids = base
2721 .hooks
2722 .entries
2723 .iter()
2724 .map(|entry| entry.id.0.as_str())
2725 .collect::<Vec<_>>();
2726 assert_eq!(ids, vec!["base", "other"]);
2727 }
2728
2729 fn mcp_server(name: &str, command: &str) -> crate::mcp_config::McpServerConfig {
2732 crate::mcp_config::McpServerConfig {
2733 name: name.to_string(),
2734 transport: crate::mcp_config::McpTransportConfig::Stdio(
2735 crate::mcp_config::McpStdioConfig {
2736 command: command.to_string(),
2737 args: Vec::new(),
2738 env: std::collections::HashMap::new(),
2739 },
2740 ),
2741 connect_timeout_secs: None,
2742 }
2743 }
2744
2745 fn skill_repo(
2746 name: &str,
2747 uuid: &str,
2748 path: &str,
2749 ) -> crate::skills_config::SkillRepositoryConfig {
2750 crate::skills_config::SkillRepositoryConfig {
2751 name: name.to_string(),
2752 source_uuid: crate::skills::SourceUuid::parse(uuid).expect("valid uuid"),
2753 transport: crate::skills_config::SkillRepoTransport::Filesystem {
2754 path: path.to_string(),
2755 },
2756 }
2757 }
2758
2759 #[test]
2761 fn effective_config_unions_model_defaults_child_wins() {
2762 let mut base = Config::default();
2763 base.models.openai = "parent-openai".to_string();
2764 base.models.anthropic = "parent-anthropic".to_string();
2765 let mut child = Config::default();
2766 child.models.anthropic = "child-anthropic".to_string();
2767 base.merge(child);
2768 assert_eq!(
2769 base.models.openai, "parent-openai",
2770 "child inherits the parent's openai default"
2771 );
2772 assert_eq!(
2773 base.models.anthropic, "child-anthropic",
2774 "child overrides only its anthropic default"
2775 );
2776 }
2777
2778 #[test]
2780 fn effective_config_unions_mcp_limits_skills() {
2781 let mut base = Config::default();
2782 base.limits.budget = Some(1000);
2783 base.tools.mcp_servers = vec![mcp_server("shared", "base-cmd")];
2784 base.skills.repositories = vec![skill_repo(
2785 "base-repo",
2786 "00000000-0000-4000-8000-000000000001",
2787 "/b",
2788 )];
2789
2790 let mut child = Config::default();
2791 child.limits.max_sessions = Some(7);
2792 child.tools.mcp_servers = vec![mcp_server("shared", "child-cmd"), mcp_server("extra", "x")];
2793 child.skills.repositories = vec![skill_repo(
2794 "child-repo",
2795 "00000000-0000-4000-8000-000000000002",
2796 "/c",
2797 )];
2798
2799 base.merge(child);
2800
2801 assert_eq!(base.limits.budget, Some(1000));
2803 assert_eq!(base.limits.max_sessions, Some(7));
2804
2805 let names: Vec<&str> = base
2807 .tools
2808 .mcp_servers
2809 .iter()
2810 .map(|s| s.name.as_str())
2811 .collect();
2812 assert_eq!(names, vec!["shared", "extra"]);
2813 let shared = base
2814 .tools
2815 .mcp_servers
2816 .iter()
2817 .find(|s| s.name == "shared")
2818 .unwrap();
2819 assert!(
2820 matches!(
2821 &shared.transport,
2822 crate::mcp_config::McpTransportConfig::Stdio(c) if c.command == "child-cmd"
2823 ),
2824 "child overrides the same-named inherited server"
2825 );
2826
2827 let repos: Vec<&str> = base
2829 .skills
2830 .repositories
2831 .iter()
2832 .map(|r| r.name.as_str())
2833 .collect();
2834 assert_eq!(repos, vec!["base-repo", "child-repo"]);
2835 }
2836
2837 #[test]
2839 fn config_merge_folds_realm_map_child_wins() {
2840 let mut base = Config::default();
2841 base.realm.insert(
2842 "global".to_string(),
2843 crate::connection::RealmConfigSection::default(),
2844 );
2845 let mut child = Config::default();
2846 child.realm.insert(
2847 "team".to_string(),
2848 crate::connection::RealmConfigSection {
2849 parent: Some(crate::connection::RealmId::global()),
2850 ..Default::default()
2851 },
2852 );
2853 base.merge(child);
2854 assert!(base.realm.contains_key("global"), "inherited realm visible");
2855 assert!(base.realm.contains_key("team"));
2856 assert_eq!(
2857 base.realm.get("team").and_then(|s| s.parent.clone()),
2858 Some(crate::connection::RealmId::global())
2859 );
2860 }
2861
2862 #[test]
2864 fn child_cannot_remove_inherited_mcp_or_hook_entries() {
2865 let mut base = Config::default();
2866 base.tools.mcp_servers = vec![mcp_server("inherited", "cmd")];
2867 base.hooks.entries.push(HookEntryConfig {
2868 id: HookId::new("inherited-hook"),
2869 ..HookEntryConfig::default()
2870 });
2871
2872 let child = Config::default();
2875 base.merge(child);
2876
2877 assert_eq!(
2878 base.tools.mcp_servers.len(),
2879 1,
2880 "empty child must not remove an inherited mcp server"
2881 );
2882 assert_eq!(base.tools.mcp_servers[0].name, "inherited");
2883 assert!(
2884 base.hooks
2885 .entries
2886 .iter()
2887 .any(|h| h.id.0.as_str() == "inherited-hook"),
2888 "empty child must not remove an inherited hook"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_merge_self_hosted_preserves_lower_layer_servers_and_models() {
2894 let mut base = Config::default();
2895 base.merge_toml_str(
2896 r#"
2897[self_hosted.servers.local]
2898base_url = "http://127.0.0.1:11434"
2899"#,
2900 )
2901 .expect("base self-hosted server");
2902 base.merge_toml_str(
2903 r#"
2904[self_hosted.models.gemma-4-e2b]
2905server = "local"
2906remote_model = "gemma4:e2b"
2907display_name = "Gemma 4 E2B"
2908family = "gemma-4"
2909"#,
2910 )
2911 .expect("overlay self-hosted model");
2912
2913 assert!(base.self_hosted.servers.contains_key("local"));
2914 assert!(base.self_hosted.models.contains_key("gemma-4-e2b"));
2915 let registry = base
2916 .model_registry(*crate::model_profile::test_catalog::TEST_CATALOG)
2917 .expect("merged self-hosted registry");
2918 assert_eq!(
2919 registry
2920 .entry("gemma-4-e2b")
2921 .and_then(|entry| entry.self_hosted.as_ref())
2922 .map(|server| server.server_id.as_str()),
2923 Some("local")
2924 );
2925 }
2926
2927 #[test]
2928 fn test_merge_self_hosted_partial_server_override_preserves_existing_fields() {
2929 let mut config = Config::default();
2930 config
2931 .merge_toml_str(
2932 r#"
2933[self_hosted.servers.local]
2934base_url = "http://127.0.0.1:11434"
2935api_style = "responses"
2936"#,
2937 )
2938 .expect("base server");
2939 config
2940 .merge_toml_str(
2941 r#"
2942[self_hosted.servers.local]
2943transport = "openai_compatible"
2944"#,
2945 )
2946 .expect("overlay server");
2947
2948 let server = config
2949 .self_hosted
2950 .servers
2951 .get("local")
2952 .expect("merged server");
2953 assert_eq!(server.base_url, "http://127.0.0.1:11434");
2954 assert_eq!(server.api_style, SelfHostedApiStyle::Responses);
2955 assert_eq!(server.transport, SelfHostedTransport::OpenAiCompatible);
2956 }
2957
2958 #[test]
2959 fn test_merge_self_hosted_partial_override_preserves_unrelated_inherited_entries() {
2960 let mut config = Config::default();
2961 config
2962 .merge_toml_str(
2963 r#"
2964[self_hosted]
2965default_model = "gemma-4-e4b"
2966
2967[self_hosted.servers.local]
2968base_url = "http://127.0.0.1:11434"
2969
2970[self_hosted.servers.backup]
2971base_url = "http://127.0.0.1:11435"
2972
2973[self_hosted.models.gemma-4-e2b]
2974server = "local"
2975remote_model = "gemma4:e2b"
2976display_name = "Gemma 4 E2B"
2977family = "gemma-4"
2978
2979[self_hosted.models.gemma-4-e4b]
2980server = "backup"
2981remote_model = "gemma4:e4b"
2982display_name = "Gemma 4 E4B"
2983family = "gemma-4"
2984"#,
2985 )
2986 .expect("base self-hosted config");
2987 config
2988 .merge_toml_str(
2989 r#"
2990[self_hosted.servers.local]
2991api_style = "responses"
2992"#,
2993 )
2994 .expect("overlay self-hosted config");
2995
2996 assert!(config.self_hosted.servers.contains_key("backup"));
2997 assert!(config.self_hosted.models.contains_key("gemma-4-e4b"));
2998 let registry = config
2999 .model_registry(*crate::model_profile::test_catalog::TEST_CATALOG)
3000 .expect("registry should remain valid");
3001 assert_eq!(
3002 registry
3003 .entry("gemma-4-e4b")
3004 .and_then(|entry| entry.self_hosted.as_ref())
3005 .map(|server| server.server_id.as_str()),
3006 Some("backup")
3007 );
3008 }
3009
3010 #[test]
3011 fn test_merge_self_hosted_empty_table_clears_inherited_entries() {
3012 let mut config = Config::default();
3013 config
3014 .merge_toml_str(
3015 r#"
3016[self_hosted.servers.local]
3017base_url = "http://127.0.0.1:11434"
3018
3019[self_hosted.models.gemma-4-e2b]
3020server = "local"
3021remote_model = "gemma4:e2b"
3022display_name = "Gemma 4 E2B"
3023family = "gemma-4"
3024"#,
3025 )
3026 .expect("base self-hosted config");
3027
3028 config
3029 .merge_toml_str(
3030 r"
3031[self_hosted.servers]
3032
3033[self_hosted.models]
3034",
3035 )
3036 .expect("clear self-hosted config");
3037
3038 assert!(config.self_hosted.servers.is_empty());
3039 assert!(config.self_hosted.models.is_empty());
3040 }
3041
3042 #[test]
3043 fn test_self_hosted_legacy_bearer_token_rejected_at_parse() {
3044 let err = toml::from_str::<Config>(
3048 r#"
3049[self_hosted.servers.local]
3050base_url = "http://127.0.0.1:11434"
3051bearer_token = "secret-token"
3052"#,
3053 )
3054 .expect_err("legacy bearer_token must be rejected at config parse");
3055 assert!(
3056 err.to_string().contains("bearer_token"),
3057 "rejection must name the offending field: {err}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_self_hosted_legacy_bearer_token_env_rejected_at_parse() {
3063 let err = toml::from_str::<Config>(
3064 r#"
3065[self_hosted.servers.local]
3066base_url = "http://127.0.0.1:11434"
3067bearer_token_env = "OLLAMA_TOKEN"
3068"#,
3069 )
3070 .expect_err("legacy bearer_token_env must be rejected at config parse");
3071 assert!(
3072 err.to_string().contains("bearer_token_env"),
3073 "rejection must name the offending field: {err}"
3074 );
3075 }
3076
3077 #[test]
3078 fn test_self_hosted_legacy_bearer_token_rejected_in_layered_merge() {
3079 let mut config = Config::default();
3083 config
3084 .merge_toml_str(
3085 r#"
3086[self_hosted.servers.local]
3087base_url = "http://127.0.0.1:11434"
3088"#,
3089 )
3090 .expect("base server");
3091 let err = config
3092 .merge_toml_str(
3093 r#"
3094[self_hosted.servers.local]
3095bearer_token_env = "OLLAMA_TOKEN"
3096"#,
3097 )
3098 .expect_err("legacy bearer_token_env overlay must be rejected");
3099 assert!(matches!(err, ConfigError::Parse(_)));
3100 assert!(err.to_string().contains("bearer_token_env"));
3101 }
3102
3103 #[test]
3112 fn test_merge_toml_tools_omitted_fields_preserve_lower_layer() {
3113 let mut config = Config::default();
3114 config.tools.mob_enabled = true;
3115 config.tools.shell_enabled = true;
3116
3117 config
3118 .merge_toml_str(
3119 r"
3120[tools]
3121shell_enabled = false
3122",
3123 )
3124 .expect("merge should succeed");
3125
3126 assert!(config.tools.mob_enabled);
3127 assert!(!config.tools.shell_enabled);
3128 }
3129
3130 #[test]
3131 fn test_merge_toml_tools_explicit_default_overrides_lower_layer() {
3132 let mut config = Config::default();
3133 config.tools.mob_enabled = true;
3134
3135 config
3136 .merge_toml_str(
3137 r"
3138[tools]
3139mob_enabled = false
3140",
3141 )
3142 .expect("merge should succeed");
3143
3144 assert!(!config.tools.mob_enabled);
3145 }
3146
3147 #[test]
3148 fn test_merge_toml_retry_omitted_fields_preserve_lower_layer() {
3149 let mut config = Config::default();
3150 config.retry.max_retries = 9;
3151
3152 config
3153 .merge_toml_str(
3154 r#"
3155[retry]
3156initial_delay = "750ms"
3157"#,
3158 )
3159 .expect("merge should succeed");
3160
3161 assert_eq!(config.retry.max_retries, 9);
3162 assert_eq!(config.retry.initial_delay, Duration::from_millis(750));
3163 }
3164
3165 #[test]
3166 fn test_compaction_threshold_presence_is_preserved_at_default_value() {
3167 let config: Config = toml::from_str(
3168 r"
3169[compaction]
3170auto_compact_threshold = 100000
3171",
3172 )
3173 .expect("config should parse");
3174
3175 assert_eq!(config.compaction.auto_compact_threshold, 100_000);
3176 assert!(config.compaction.auto_compact_threshold_explicit);
3177 }
3178
3179 #[test]
3180 fn test_default_compaction_threshold_serializes_as_inherited() {
3181 let toml = toml::to_string_pretty(&Config::default()).expect("config should serialize");
3182
3183 assert!(
3184 !toml.contains("auto_compact_threshold"),
3185 "default config should not persist an inherited compaction threshold: {toml}"
3186 );
3187 }
3188
3189 #[test]
3190 fn test_explicit_default_compaction_threshold_serializes() {
3191 let mut config = Config::default();
3192 config.compaction.auto_compact_threshold_explicit = true;
3193
3194 let toml = toml::to_string_pretty(&config).expect("config should serialize");
3195
3196 assert!(
3197 toml.contains("auto_compact_threshold = 100000"),
3198 "explicit default threshold must survive persistence: {toml}"
3199 );
3200 }
3201
3202 #[test]
3203 fn test_validate_rejects_zero_min_turns_between_compactions() {
3204 let config = Config {
3205 compaction: CompactionRuntimeConfig {
3206 min_turns_between_compactions: 0,
3207 ..CompactionRuntimeConfig::default()
3208 },
3209 ..Config::default()
3210 };
3211 let err = config
3212 .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3213 .expect_err("min_turns_between_compactions=0 should be invalid");
3214 assert!(
3215 err.to_string()
3216 .contains("compaction.min_turns_between_compactions")
3217 );
3218 }
3219
3220 #[test]
3226 fn test_budget_config_serialization() {
3227 let budget = BudgetConfig {
3228 max_tokens: Some(100_000),
3229 max_duration: Some(Duration::from_secs(300)),
3230 max_tool_calls: Some(50),
3231 };
3232
3233 let json = serde_json::to_string(&budget).unwrap();
3234 let parsed: BudgetConfig = serde_json::from_str(&json).unwrap();
3235
3236 assert_eq!(parsed.max_tokens, Some(100_000));
3237 assert_eq!(parsed.max_duration, Some(Duration::from_secs(300)));
3238 assert_eq!(parsed.max_tool_calls, Some(50));
3239 }
3240
3241 #[test]
3242 fn test_self_hosted_transport_accepts_openai_compatible_alias() {
3243 let mut config = Config::default();
3244 config
3245 .merge_toml_str(
3246 r#"
3247[self_hosted.servers.ollama]
3248transport = "openai_compatible"
3249base_url = "http://127.0.0.1:11434"
3250api_style = "chat_completions"
3251"#,
3252 )
3253 .expect("alias should parse");
3254
3255 assert_eq!(
3256 config
3257 .self_hosted
3258 .servers
3259 .get("ollama")
3260 .expect("server should exist")
3261 .transport,
3262 SelfHostedTransport::OpenAiCompatible
3263 );
3264 }
3265
3266 #[test]
3267 fn test_self_hosted_server_config_defaults_to_chat_completions() {
3268 assert_eq!(
3269 SelfHostedServerConfig::default().api_style,
3270 SelfHostedApiStyle::ChatCompletions
3271 );
3272 }
3273
3274 #[test]
3275 fn test_retry_config_to_policy() {
3276 let config = RetryConfig::default();
3277 let policy: RetryPolicy = config.into();
3278
3279 assert_eq!(policy.max_retries, 3);
3280 assert_eq!(policy.initial_delay, Duration::from_millis(500));
3281 }
3282
3283 #[tokio::test]
3287 async fn test_regression_load_succeeds_without_config_toml() {
3288 use tempfile::TempDir;
3289
3290 let temp_dir = TempDir::new().unwrap();
3292 let rkat_dir = temp_dir.path().join(".rkat");
3293 std::fs::create_dir(&rkat_dir).unwrap();
3294
3295 assert!(rkat_dir.exists());
3297 assert!(!rkat_dir.join("config.toml").exists());
3298
3299 let result =
3301 Config::load_from_with_env(temp_dir.path(), Some(temp_dir.path()), |_| None).await;
3302
3303 assert!(
3304 result.is_ok(),
3305 "Config::load() should succeed when .rkat/ exists without config.toml: {:?}",
3306 result.err()
3307 );
3308 }
3309
3310 #[test]
3311 fn test_validate_rejects_zero_max_tokens() {
3312 let config = Config {
3313 max_tokens: Some(0),
3314 ..Config::default()
3315 };
3316 let err = config
3317 .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3318 .expect_err("max_tokens=0 should be invalid");
3319 assert!(
3320 err.to_string()
3321 .contains("max_tokens must be greater than 0")
3322 );
3323 }
3324
3325 #[test]
3326 fn test_validate_rejects_zero_limits_max_sessions() {
3327 let mut config = Config::default();
3328 config.limits.max_sessions = Some(0);
3329 let err = config
3330 .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3331 .expect_err("limits.max_sessions=0 should be invalid");
3332 assert!(err.to_string().contains("limits.max_sessions"));
3333 }
3334
3335 #[test]
3336 fn test_validate_rejects_zero_agent_max_tokens_per_turn() {
3337 let mut config = Config::default();
3338 config.agent.max_tokens_per_turn = Some(0);
3339 let err = config
3340 .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3341 .expect_err("agent.max_tokens_per_turn=0 should be invalid");
3342 assert!(err.to_string().contains("agent.max_tokens_per_turn"));
3343 }
3344
3345 #[test]
3350 fn test_provider_parse_strict() {
3351 assert_eq!(
3352 Provider::parse_strict("anthropic"),
3353 Some(Provider::Anthropic)
3354 );
3355 assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
3356 assert_eq!(Provider::parse_strict("gemini"), Some(Provider::Gemini));
3357 assert_eq!(Provider::parse_strict("other"), None);
3358 assert_eq!(Provider::parse_strict("claude"), None);
3359 assert_eq!(Provider::parse_strict(""), None);
3360 }
3361
3362 #[test]
3365 fn test_comms_auth_mode_default_is_open() {
3366 assert_eq!(CommsAuthMode::default(), CommsAuthMode::Open);
3367 }
3368
3369 #[test]
3370 fn test_comms_auth_mode_serde_roundtrip() {
3371 let json = serde_json::to_string(&CommsAuthMode::Open).unwrap();
3373 assert_eq!(json, r#""none""#);
3374 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
3375 assert_eq!(parsed, CommsAuthMode::Open);
3376
3377 let json = serde_json::to_string(&CommsAuthMode::Ed25519).unwrap();
3379 assert_eq!(json, r#""ed25519""#);
3380 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
3381 assert_eq!(parsed, CommsAuthMode::Ed25519);
3382 }
3383
3384 #[test]
3385 fn test_comms_auth_mode_toml_roundtrip() {
3386 let config = CommsRuntimeConfig::default();
3387 let toml_str = toml::to_string(&config).unwrap();
3388 let parsed: CommsRuntimeConfig = toml::from_str(&toml_str).unwrap();
3389 assert_eq!(parsed.auth, CommsAuthMode::Open);
3390 assert!(parsed.require_peer_auth);
3391
3392 let toml_str = r#"
3394mode = "inproc"
3395auth = "ed25519"
3396"#;
3397 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
3398 assert_eq!(parsed.auth, CommsAuthMode::Ed25519);
3399 assert!(parsed.require_peer_auth);
3400 }
3401
3402 #[test]
3403 fn test_comms_runtime_config_default_has_open_auth() {
3404 let config = CommsRuntimeConfig::default();
3405 assert_eq!(config.auth, CommsAuthMode::Open);
3406 assert!(config.require_peer_auth);
3407 }
3408
3409 #[test]
3412 fn test_plain_event_source_serde_roundtrip() {
3413 let cases = [
3414 (PlainEventSource::Tcp, r#""tcp""#),
3415 (PlainEventSource::Uds, r#""uds""#),
3416 (PlainEventSource::Stdin, r#""stdin""#),
3417 (PlainEventSource::Webhook, r#""webhook""#),
3418 (PlainEventSource::Rpc, r#""rpc""#),
3419 ];
3420 for (variant, expected_json) in cases {
3421 let json = serde_json::to_string(&variant).unwrap();
3422 assert_eq!(json, expected_json, "serialize {variant:?}");
3423 let parsed: PlainEventSource = serde_json::from_str(&json).unwrap();
3424 assert_eq!(parsed, variant, "deserialize {variant:?}");
3425 }
3426 }
3427
3428 #[test]
3429 fn test_plain_event_source_display() {
3430 assert_eq!(PlainEventSource::Tcp.to_string(), "tcp");
3431 assert_eq!(PlainEventSource::Uds.to_string(), "uds");
3432 assert_eq!(PlainEventSource::Stdin.to_string(), "stdin");
3433 assert_eq!(PlainEventSource::Webhook.to_string(), "webhook");
3434 assert_eq!(PlainEventSource::Rpc.to_string(), "rpc");
3435 }
3436
3437 #[test]
3440 fn test_comms_config_event_address_toml_roundtrip() {
3441 let toml_str = r#"
3442mode = "tcp"
3443address = "127.0.0.1:4200"
3444advertise_address = "tcp://203.0.113.10:4200"
3445auth = "none"
3446require_peer_auth = false
3447event_address = "127.0.0.1:4201"
3448"#;
3449 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
3450 assert_eq!(parsed.event_address.as_deref(), Some("127.0.0.1:4201"));
3451 assert_eq!(
3452 parsed.advertise_address.as_deref(),
3453 Some("tcp://203.0.113.10:4200")
3454 );
3455 assert_eq!(parsed.auth, CommsAuthMode::Open);
3456 assert!(!parsed.require_peer_auth);
3457 }
3458
3459 #[test]
3460 fn test_comms_config_event_address_defaults_none() {
3461 let config = CommsRuntimeConfig::default();
3462 assert!(config.event_address.is_none());
3463 }
3464
3465 #[test]
3468 fn call_timeout_override_default_is_inherit() {
3469 assert_eq!(CallTimeoutOverride::default(), CallTimeoutOverride::Inherit);
3470 assert!(CallTimeoutOverride::default().is_inherit());
3471 }
3472
3473 #[test]
3474 fn call_timeout_override_disabled_is_not_inherit() {
3475 assert!(!CallTimeoutOverride::Disabled.is_inherit());
3476 }
3477
3478 #[test]
3479 fn call_timeout_override_value_is_not_inherit() {
3480 assert!(!CallTimeoutOverride::Value(Duration::from_secs(45)).is_inherit());
3481 }
3482
3483 #[test]
3484 fn call_timeout_override_toml_deserialize_disabled() {
3485 let toml_str = r#"call_timeout = "disabled""#;
3486 #[derive(Deserialize)]
3487 struct Wrapper {
3488 call_timeout: CallTimeoutOverride,
3489 }
3490 let w: Wrapper = toml::from_str(toml_str).unwrap();
3491 assert_eq!(w.call_timeout, CallTimeoutOverride::Disabled);
3492 }
3493
3494 #[test]
3495 fn call_timeout_override_toml_deserialize_duration() {
3496 let toml_str = r#"call_timeout = "45s""#;
3497 #[derive(Deserialize)]
3498 struct Wrapper {
3499 call_timeout: CallTimeoutOverride,
3500 }
3501 let w: Wrapper = toml::from_str(toml_str).unwrap();
3502 assert_eq!(
3503 w.call_timeout,
3504 CallTimeoutOverride::Value(Duration::from_secs(45))
3505 );
3506 }
3507
3508 #[test]
3509 fn call_timeout_override_toml_deserialize_complex_duration() {
3510 let toml_str = r#"call_timeout = "2m 30s""#;
3511 #[derive(Deserialize)]
3512 struct Wrapper {
3513 call_timeout: CallTimeoutOverride,
3514 }
3515 let w: Wrapper = toml::from_str(toml_str).unwrap();
3516 assert_eq!(
3517 w.call_timeout,
3518 CallTimeoutOverride::Value(Duration::from_secs(150))
3519 );
3520 }
3521
3522 #[derive(Debug, PartialEq, Serialize, Deserialize)]
3525 struct SystemPromptWrapper {
3526 #[serde(default, skip_serializing_if = "SystemPromptOverride::is_inherit")]
3527 system_prompt: SystemPromptOverride,
3528 }
3529
3530 #[test]
3531 fn system_prompt_override_default_is_inherit() {
3532 assert_eq!(
3533 SystemPromptOverride::default(),
3534 SystemPromptOverride::Inherit
3535 );
3536 assert!(SystemPromptOverride::default().is_inherit());
3537 assert!(!SystemPromptOverride::default().is_explicit());
3538 assert!(SystemPromptOverride::Set("p".to_string()).is_explicit());
3539 assert!(SystemPromptOverride::Disable.is_explicit());
3540 assert_eq!(
3541 SystemPromptOverride::Set("p".to_string()).as_set_prompt(),
3542 Some("p")
3543 );
3544 assert_eq!(SystemPromptOverride::Disable.as_set_prompt(), None);
3545 }
3546
3547 #[test]
3548 fn system_prompt_override_absent_field_parses_as_inherit() {
3549 let w: SystemPromptWrapper = serde_json::from_str("{}").unwrap();
3552 assert_eq!(w.system_prompt, SystemPromptOverride::Inherit);
3553 }
3554
3555 #[test]
3556 fn system_prompt_override_null_parses_as_inherit() {
3557 let w: SystemPromptWrapper = serde_json::from_str(r#"{"system_prompt": null}"#).unwrap();
3558 assert_eq!(w.system_prompt, SystemPromptOverride::Inherit);
3559 }
3560
3561 #[test]
3562 fn system_prompt_override_string_round_trips_as_set() {
3563 let w: SystemPromptWrapper =
3565 serde_json::from_str(r#"{"system_prompt": "You are helpful."}"#).unwrap();
3566 assert_eq!(
3567 w.system_prompt,
3568 SystemPromptOverride::Set("You are helpful.".to_string())
3569 );
3570 let json = serde_json::to_string(&w).unwrap();
3571 assert_eq!(json, r#"{"system_prompt":"You are helpful."}"#);
3572 let back: SystemPromptWrapper = serde_json::from_str(&json).unwrap();
3573 assert_eq!(back, w);
3574 }
3575
3576 #[test]
3577 fn system_prompt_override_disable_round_trips() {
3578 let w = SystemPromptWrapper {
3581 system_prompt: SystemPromptOverride::Disable,
3582 };
3583 let json = serde_json::to_string(&w).unwrap();
3584 assert_eq!(json, r#"{"system_prompt":{"action":"disable"}}"#);
3585 let back: SystemPromptWrapper = serde_json::from_str(&json).unwrap();
3586 assert_eq!(back.system_prompt, SystemPromptOverride::Disable);
3587 }
3588
3589 #[test]
3590 fn system_prompt_override_inherit_is_omitted_when_serialized() {
3591 let w = SystemPromptWrapper {
3592 system_prompt: SystemPromptOverride::Inherit,
3593 };
3594 assert_eq!(serde_json::to_string(&w).unwrap(), "{}");
3595 }
3596
3597 #[test]
3598 fn system_prompt_override_unknown_action_rejected() {
3599 let err = serde_json::from_str::<SystemPromptWrapper>(
3600 r#"{"system_prompt": {"action": "clear"}}"#,
3601 )
3602 .expect_err("unknown action must be rejected");
3603 assert!(
3604 err.to_string()
3605 .contains("unknown system_prompt override action")
3606 );
3607 }
3608
3609 #[test]
3610 fn system_prompt_override_unknown_key_rejected() {
3611 serde_json::from_str::<SystemPromptWrapper>(r#"{"system_prompt": {"disable": true}}"#)
3612 .expect_err("object form must carry exactly the action key");
3613 }
3614
3615 #[test]
3616 fn system_prompt_override_non_string_rejected() {
3617 serde_json::from_str::<SystemPromptWrapper>(r#"{"system_prompt": 42}"#)
3618 .expect_err("numeric system_prompt must be rejected");
3619 }
3620
3621 #[test]
3622 fn retry_config_default_has_inherit_call_timeout() {
3623 let config = RetryConfig::default();
3624 assert_eq!(config.call_timeout_override, CallTimeoutOverride::Inherit);
3625 }
3626
3627 #[test]
3628 fn retry_config_from_toml_with_call_timeout_value() {
3629 let toml_str = r#"
3630[retry]
3631max_retries = 5
3632call_timeout = "60s"
3633"#;
3634 let config: Config = toml::from_str(toml_str).unwrap();
3635 assert_eq!(config.retry.max_retries, 5);
3636 assert_eq!(
3637 config.retry.call_timeout_override,
3638 CallTimeoutOverride::Value(Duration::from_secs(60))
3639 );
3640 }
3641
3642 #[test]
3643 fn retry_config_from_toml_with_call_timeout_disabled() {
3644 let toml_str = r#"
3645[retry]
3646call_timeout = "disabled"
3647"#;
3648 let config: Config = toml::from_str(toml_str).unwrap();
3649 assert_eq!(
3650 config.retry.call_timeout_override,
3651 CallTimeoutOverride::Disabled
3652 );
3653 }
3654
3655 #[test]
3656 fn retry_config_from_toml_omitted_is_inherit() {
3657 let toml_str = r"
3658[retry]
3659max_retries = 2
3660";
3661 let config: Config = toml::from_str(toml_str).unwrap();
3662 assert_eq!(
3663 config.retry.call_timeout_override,
3664 CallTimeoutOverride::Inherit
3665 );
3666 }
3667
3668 #[test]
3669 fn retry_policy_from_config_with_value_override() {
3670 let config = RetryConfig {
3671 call_timeout_override: CallTimeoutOverride::Value(Duration::from_secs(90)),
3672 ..RetryConfig::default()
3673 };
3674 let policy: crate::retry::RetryPolicy = config.into();
3675 assert_eq!(policy.call_timeout, Some(Duration::from_secs(90)));
3676 }
3677
3678 #[test]
3679 fn retry_policy_from_config_with_disabled_override() {
3680 let config = RetryConfig {
3681 call_timeout_override: CallTimeoutOverride::Disabled,
3682 ..RetryConfig::default()
3683 };
3684 let policy: crate::retry::RetryPolicy = config.into();
3685 assert_eq!(policy.call_timeout, None);
3687 }
3688
3689 #[test]
3690 fn retry_policy_from_config_with_inherit_override() {
3691 let config = RetryConfig {
3692 call_timeout_override: CallTimeoutOverride::Inherit,
3693 ..RetryConfig::default()
3694 };
3695 let policy: crate::retry::RetryPolicy = config.into();
3696 assert_eq!(policy.call_timeout, None);
3697 }
3698
3699 #[test]
3700 fn config_merge_preserves_call_timeout_override() {
3701 let toml_base = r"
3702[retry]
3703max_retries = 2
3704";
3705 let toml_overlay = r#"
3706[retry]
3707call_timeout = "30s"
3708"#;
3709 let mut config: Config = toml::from_str(toml_base).unwrap();
3710 let overlay: Config = toml::from_str(toml_overlay).unwrap();
3711 let overlay_parsed: toml::Value = toml::from_str(toml_overlay).unwrap();
3712 config.merge_retry_from_toml_presence(&overlay_parsed, &overlay.retry);
3713 assert_eq!(config.retry.max_retries, 2); assert_eq!(
3715 config.retry.call_timeout_override,
3716 CallTimeoutOverride::Value(Duration::from_secs(30))
3717 );
3718 }
3719
3720 #[test]
3723 fn test_provider_tools_defaults_all_enabled() {
3724 let config = Config::default();
3725 assert!(config.provider_tools.anthropic.web_search);
3726 assert!(config.provider_tools.openai.web_search);
3727 assert!(config.provider_tools.gemini.google_search);
3728 }
3729
3730 #[test]
3731 fn test_provider_tools_roundtrip_toml() {
3732 let config = Config::default();
3733 let toml_str = toml::to_string(&config.provider_tools).unwrap();
3734 let parsed: ProviderToolsConfig = toml::from_str(&toml_str).unwrap();
3735 assert_eq!(parsed, config.provider_tools);
3736 }
3737
3738 #[test]
3739 fn test_model_fallback_defaults_enabled_with_catalog_chain() {
3740 let config = Config::default();
3741 assert!(config.model_fallback.enabled);
3742 assert!(config.model_fallback.chain.is_empty());
3743 }
3744
3745 #[test]
3746 fn test_model_fallback_chain_parses_typed_targets() {
3747 let config: Config = toml::from_str(
3748 r#"
3749[model_fallback]
3750enabled = true
3751
3752[[model_fallback.chain]]
3753model = "backup-openai"
3754provider = "openai"
3755
3756[[model_fallback.chain]]
3757model = "backup-anthropic"
3758provider = "anthropic"
3759"#,
3760 )
3761 .unwrap();
3762
3763 assert!(config.model_fallback.enabled);
3764 assert_eq!(config.model_fallback.chain.len(), 2);
3765 assert_eq!(config.model_fallback.chain[0].model, "backup-openai");
3766 assert_eq!(
3767 config.model_fallback.chain[0].provider,
3768 Some(crate::Provider::OpenAI)
3769 );
3770 assert_eq!(
3771 config.model_fallback.chain[1].provider,
3772 Some(crate::Provider::Anthropic)
3773 );
3774 }
3775
3776 #[test]
3777 fn test_model_fallback_catalog_default_reset_overrides_custom_layer() {
3778 let mut config: Config = toml::from_str(
3779 r#"
3780[model_fallback]
3781enabled = false
3782
3783[[model_fallback.chain]]
3784model = "backup-openai"
3785provider = "openai"
3786"#,
3787 )
3788 .unwrap();
3789 let reset: Config = toml::from_str(
3790 r"
3791[model_fallback]
3792use_catalog_default_chain = true
3793",
3794 )
3795 .unwrap();
3796
3797 config.merge(reset);
3798
3799 assert_eq!(config.model_fallback, ModelFallbackConfig::default());
3800 }
3801
3802 #[test]
3803 fn test_provider_tools_merge_preserves_when_absent() {
3804 let mut config = Config::default();
3805 config
3806 .merge_toml_str(
3807 r#"[agent]
3808model = "custom-model"
3809"#,
3810 )
3811 .unwrap();
3812 assert!(config.provider_tools.anthropic.web_search);
3814 assert!(config.provider_tools.openai.web_search);
3815 assert!(config.provider_tools.gemini.google_search);
3816 }
3817
3818 #[test]
3819 fn test_provider_tools_merge_overrides_single_provider() {
3820 let mut config = Config::default();
3821 config
3822 .merge_toml_str("[provider_tools.anthropic]\nweb_search = false\n")
3823 .unwrap();
3824 assert!(!config.provider_tools.anthropic.web_search);
3826 assert!(config.provider_tools.openai.web_search);
3828 assert!(config.provider_tools.gemini.google_search);
3829 }
3830
3831 #[test]
3834 fn test_provider_tool_defaults_not_serialized() {
3835 use crate::lifecycle::run_primitive::{
3836 AnthropicProviderTag, OpaqueProviderBody, ProviderParamsCarrier, ProviderTag,
3837 };
3838
3839 let agent_config = AgentConfig {
3840 provider_params: ProviderParamsCarrier {
3841 params: Default::default(),
3842 tool_defaults: Some(ProviderTag::Anthropic(AnthropicProviderTag {
3843 web_search: Some(OpaqueProviderBody::from_value(&serde_json::json!({
3844 "type": "web_search_20250305"
3845 }))),
3846 ..Default::default()
3847 })),
3848 },
3849 ..Default::default()
3850 };
3851 let json = serde_json::to_value(&agent_config).unwrap();
3852 assert!(
3855 json.get("provider_params").is_none(),
3856 "build-derived tool defaults must not be serialized: {json}"
3857 );
3858 }
3859
3860 #[test]
3863 fn test_malformed_provider_params_rejected_at_config_parse() {
3864 let unknown_key = serde_json::json!({
3866 "model": "test-anthropic-default",
3867 "provider_params": { "not_a_known_knob": true }
3868 });
3869 assert!(
3870 serde_json::from_value::<AgentConfig>(unknown_key).is_err(),
3871 "unknown provider-params key must fail closed at config ingress"
3872 );
3873
3874 let wrong_type = serde_json::json!({
3876 "model": "test-anthropic-default",
3877 "provider_params": { "temperature": "hot" }
3878 });
3879 assert!(
3880 serde_json::from_value::<AgentConfig>(wrong_type).is_err(),
3881 "mistyped provider-params knob must fail closed at config ingress"
3882 );
3883
3884 let typed = serde_json::json!({
3886 "model": "test-anthropic-default",
3887 "provider_params": {
3888 "temperature": 0.2,
3889 "provider_tag": { "provider": "anthropic", "effort": "high" }
3890 }
3891 });
3892 let parsed: AgentConfig =
3893 serde_json::from_value(typed).expect("typed provider params parse");
3894 assert_eq!(parsed.provider_params.params.temperature, Some(0.2));
3895 }
3896}