1use crate::connection::RealmConfigSection;
6use crate::mcp_config::McpServerConfig;
7use crate::model_profile::catalog::ModelTier;
8use crate::{
9 budget::BudgetLimits,
10 hooks::{HookCapability, HookExecutionMode, HookFailurePolicy, 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::value::RawValue;
19use serde_json::{Map, Value};
20use std::collections::{BTreeMap, HashMap};
21use std::path::PathBuf;
22use std::sync::OnceLock;
23use std::time::Duration;
24
25pub const DEFAULT_MAX_SESSIONS: usize = 100_000;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Config {
32 pub agent: AgentConfig,
33 pub storage: StorageConfig,
34 pub budget: BudgetConfig,
35 pub retry: RetryConfig,
36 pub tools: ToolsConfig,
37 pub models: ModelDefaults,
39 pub max_tokens: u32,
40 pub shell: ShellDefaults,
41 pub store: StoreConfig,
42 pub comms: CommsRuntimeConfig,
43 pub compaction: CompactionRuntimeConfig,
44 pub limits: LimitsConfig,
45 pub rest: RestServerConfig,
46 pub hooks: HooksConfig,
47 pub skills: crate::skills_config::SkillsConfig,
48 pub self_hosted: SelfHostedConfig,
49 pub provider_tools: ProviderToolsConfig,
50 pub presentation: PresentationConfig,
51 #[serde(rename = "realm", default, skip_serializing_if = "BTreeMap::is_empty")]
57 pub realm: BTreeMap<String, RealmConfigSection>,
58}
59
60impl Default for Config {
61 fn default() -> Self {
62 let defaults = template_defaults();
63 let agent = AgentConfig::default();
64 let max_tokens = defaults
65 .max_tokens
66 .filter(|value| *value > 0)
67 .unwrap_or(agent.max_tokens_per_turn);
68 Self {
69 agent,
70 storage: StorageConfig::default(),
71 budget: BudgetConfig::default(),
72 retry: RetryConfig::default(),
73 tools: ToolsConfig::default(),
74 models: ModelDefaults::default(),
75 max_tokens,
76 shell: ShellDefaults::default(),
77 store: StoreConfig::default(),
78 comms: CommsRuntimeConfig::default(),
79 compaction: CompactionRuntimeConfig::default(),
80 limits: LimitsConfig::default(),
81 rest: RestServerConfig::default(),
82 hooks: HooksConfig::default(),
83 skills: crate::skills_config::SkillsConfig::default(),
84 self_hosted: SelfHostedConfig::default(),
85 provider_tools: ProviderToolsConfig::default(),
86 presentation: PresentationConfig::default(),
87 realm: BTreeMap::new(),
88 }
89 }
90}
91
92impl Config {
93 pub fn max_sessions(&self) -> usize {
95 self.limits.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS)
96 }
97
98 pub fn model_registry(&self) -> Result<crate::ModelRegistry, ConfigError> {
100 crate::ModelRegistry::from_config(self)
101 }
102
103 pub fn template_toml() -> &'static str {
105 CONFIG_TEMPLATE_TOML
106 }
107
108 pub fn template() -> Result<Self, ConfigError> {
110 toml::from_str(CONFIG_TEMPLATE_TOML).map_err(ConfigError::Parse)
111 }
112}
113
114#[cfg(not(target_arch = "wasm32"))]
116impl Config {
117 pub async fn load() -> Result<Self, ConfigError> {
121 let cwd = std::env::current_dir()?;
122 let home = dirs::home_dir();
123 Self::load_from_with_env(&cwd, home.as_deref(), |key| std::env::var(key).ok()).await
124 }
125
126 #[doc(hidden)]
132 pub async fn load_from_with_env<F>(
133 start_dir: &std::path::Path,
134 home_dir: Option<&std::path::Path>,
135 env: F,
136 ) -> Result<Self, ConfigError>
137 where
138 F: FnMut(&str) -> Option<String>,
139 {
140 let mut config = Self::default();
141
142 if let Some(path) = Self::find_project_config_from(start_dir).await {
144 config.merge_file(&path).await?;
145 } else if let Some(path) = home_dir.map(|home| home.join(".rkat/config.toml"))
146 && tokio::fs::try_exists(&path).await.unwrap_or(false)
147 {
148 config.merge_file(&path).await?;
149 }
150
151 config.apply_env_overrides_from(env)?;
153
154 Ok(config)
155 }
156
157 #[doc(hidden)]
159 pub async fn load_from(
160 start_dir: &std::path::Path,
161 home_dir: Option<&std::path::Path>,
162 ) -> Result<Self, ConfigError> {
163 Self::load_from_with_env(start_dir, home_dir, |key| std::env::var(key).ok()).await
164 }
165
166 pub async fn load_layered_hooks() -> Result<HooksConfig, ConfigError> {
171 let cwd = std::env::current_dir()?;
172 let home = dirs::home_dir();
173 Self::load_layered_hooks_from(&cwd, home.as_deref()).await
174 }
175
176 pub async fn load_layered_hooks_from(
178 start_dir: &std::path::Path,
179 home_dir: Option<&std::path::Path>,
180 ) -> Result<HooksConfig, ConfigError> {
181 let mut hooks = HooksConfig::default();
182
183 if let Some(global_path) = home_dir.map(|home| home.join(".rkat/config.toml"))
184 && tokio::fs::try_exists(&global_path).await.unwrap_or(false)
185 {
186 let content = tokio::fs::read_to_string(&global_path).await?;
187 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
188 hooks.append_entries_from(&cfg.hooks);
189 }
190
191 if let Some(project_path) = Self::find_project_config_from(start_dir).await
192 && tokio::fs::try_exists(&project_path).await.unwrap_or(false)
193 {
194 let content = tokio::fs::read_to_string(&project_path).await?;
195 let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
196 hooks.append_entries_from(&cfg.hooks);
197 }
198
199 Ok(hooks)
200 }
201}
202
203impl Config {
204 pub fn budget_limits(&self) -> BudgetLimits {
206 self.limits.to_budget_limits()
207 }
208
209 #[cfg(not(target_arch = "wasm32"))]
211 pub fn global_config_path() -> Option<PathBuf> {
212 dirs::home_dir().map(|h| h.join(".rkat/config.toml"))
213 }
214
215 #[cfg(not(target_arch = "wasm32"))]
221 async fn find_project_config_from(start_dir: &std::path::Path) -> Option<PathBuf> {
222 let mut current = start_dir.to_path_buf();
223 loop {
224 let marker_dir = current.join(".rkat");
225 let config_path = marker_dir.join("config.toml");
226
227 let config_exists = tokio::fs::try_exists(&config_path).await.unwrap_or(false);
229 if config_exists {
230 return Some(config_path);
231 }
232
233 if !current.pop() {
234 return None;
235 }
236 }
237 }
238
239 #[cfg(not(target_arch = "wasm32"))]
241 pub async fn merge_file(&mut self, path: &PathBuf) -> Result<(), ConfigError> {
242 let content = tokio::fs::read_to_string(path).await?;
243 self.merge_toml_str(&content)
244 }
245
246 pub fn merge_toml_str(&mut self, content: &str) -> Result<(), ConfigError> {
248 let file_config: Config = toml::from_str(content).map_err(ConfigError::Parse)?;
249 let tools_layer = file_config.tools.clone();
250 let retry_layer = file_config.retry.clone();
251 let self_hosted_layer = file_config.self_hosted.clone();
252 let provider_tools_layer = file_config.provider_tools.clone();
253 self.merge(file_config);
255 let parsed: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
256 self.merge_tools_from_toml_presence(&parsed, &tools_layer);
257 self.merge_retry_from_toml_presence(&parsed, &retry_layer);
258 self.merge_self_hosted_from_toml_presence(&parsed, &self_hosted_layer);
259 self.merge_provider_tools_from_toml_presence(&parsed, &provider_tools_layer);
260 Ok(())
261 }
262
263 fn merge(&mut self, other: Config) {
273 if other.agent.system_prompt.is_some() {
275 self.agent.system_prompt = other.agent.system_prompt;
276 }
277 if other.agent.tool_instructions.is_some() {
278 self.agent.tool_instructions = other.agent.tool_instructions;
279 }
280 if other.agent.model != AgentConfig::default().model {
281 self.agent.model = other.agent.model;
282 }
283 if other.agent.max_tokens_per_turn != AgentConfig::default().max_tokens_per_turn {
284 self.agent.max_tokens_per_turn = other.agent.max_tokens_per_turn;
285 }
286 if other.agent.extraction_prompt.is_some() {
287 self.agent.extraction_prompt = other.agent.extraction_prompt;
288 }
289
290 if other.storage.directory.is_some() {
292 self.storage.directory = other.storage.directory;
293 }
294
295 if other.budget.max_tokens.is_some() {
297 self.budget.max_tokens = other.budget.max_tokens;
298 }
299 if other.budget.max_duration.is_some() {
300 self.budget.max_duration = other.budget.max_duration;
301 }
302 if other.budget.max_tool_calls.is_some() {
303 self.budget.max_tool_calls = other.budget.max_tool_calls;
304 }
305 self.merge_retry(&other.retry);
306 self.merge_tools(&other.tools);
307
308 if other.models != ModelDefaults::default() {
310 self.models = other.models;
311 }
312 if other.max_tokens != Config::default().max_tokens {
313 self.max_tokens = other.max_tokens;
314 }
315 if other.shell != ShellDefaults::default() {
316 self.shell = other.shell;
317 }
318 if other.store != StoreConfig::default() {
319 self.store = other.store;
320 }
321 if other.comms != CommsRuntimeConfig::default() {
322 self.comms = other.comms;
323 }
324 if other.compaction != CompactionRuntimeConfig::default() {
325 self.compaction = other.compaction;
326 }
327 if other.limits != LimitsConfig::default() {
328 self.limits = other.limits;
329 }
330 if other.rest != RestServerConfig::default() {
331 self.rest = other.rest;
332 }
333 if other.hooks != HooksConfig::default() {
334 let default_hooks = HooksConfig::default();
335 if other.hooks.default_timeout_ms != default_hooks.default_timeout_ms {
336 self.hooks.default_timeout_ms = other.hooks.default_timeout_ms;
337 }
338 if other.hooks.payload_max_bytes != default_hooks.payload_max_bytes {
339 self.hooks.payload_max_bytes = other.hooks.payload_max_bytes;
340 }
341 if other.hooks.background_max_concurrency != default_hooks.background_max_concurrency {
342 self.hooks.background_max_concurrency = other.hooks.background_max_concurrency;
343 }
344 self.hooks.entries.extend(other.hooks.entries);
345 }
346 }
347
348 fn merge_retry(&mut self, other: &RetryConfig) {
349 let defaults = RetryConfig::default();
350 if other.max_retries != defaults.max_retries {
351 self.retry.max_retries = other.max_retries;
352 }
353 if other.initial_delay != defaults.initial_delay {
354 self.retry.initial_delay = other.initial_delay;
355 }
356 if other.max_delay != defaults.max_delay {
357 self.retry.max_delay = other.max_delay;
358 }
359 if other.multiplier != defaults.multiplier {
360 self.retry.multiplier = other.multiplier;
361 }
362 if other.call_timeout_override != defaults.call_timeout_override {
363 self.retry.call_timeout_override = other.call_timeout_override.clone();
364 }
365 }
366
367 fn merge_tools(&mut self, other: &ToolsConfig) {
368 let defaults = ToolsConfig::default();
369 if !other.mcp_servers.is_empty() {
370 self.tools.mcp_servers.clone_from(&other.mcp_servers);
371 }
372 if other.default_timeout != defaults.default_timeout {
373 self.tools.default_timeout = other.default_timeout;
374 }
375 if other.tool_timeouts != defaults.tool_timeouts {
376 self.tools.tool_timeouts.clone_from(&other.tool_timeouts);
377 }
378 if other.max_concurrent != defaults.max_concurrent {
379 self.tools.max_concurrent = other.max_concurrent;
380 }
381 if other.builtins_enabled != defaults.builtins_enabled {
382 self.tools.builtins_enabled = other.builtins_enabled;
383 }
384 if other.shell_enabled != defaults.shell_enabled {
385 self.tools.shell_enabled = other.shell_enabled;
386 }
387 if other.comms_enabled != defaults.comms_enabled {
388 self.tools.comms_enabled = other.comms_enabled;
389 }
390 if other.mob_enabled != defaults.mob_enabled {
391 self.tools.mob_enabled = other.mob_enabled;
392 }
393 if other.schedule_enabled != defaults.schedule_enabled {
394 self.tools.schedule_enabled = other.schedule_enabled;
395 }
396 if other.workgraph_enabled != defaults.workgraph_enabled {
397 self.tools.workgraph_enabled = other.workgraph_enabled;
398 }
399 }
400
401 fn merge_tools_from_toml_presence(&mut self, parsed: &toml::Value, layer: &ToolsConfig) {
402 let Some(tools) = parsed.get("tools").and_then(toml::Value::as_table) else {
403 return;
404 };
405 if tools.contains_key("mcp_servers") {
406 self.tools.mcp_servers.clone_from(&layer.mcp_servers);
407 }
408 if tools.contains_key("default_timeout") {
409 self.tools.default_timeout = layer.default_timeout;
410 }
411 if tools.contains_key("tool_timeouts") {
412 self.tools.tool_timeouts.clone_from(&layer.tool_timeouts);
413 }
414 if tools.contains_key("max_concurrent") {
415 self.tools.max_concurrent = layer.max_concurrent;
416 }
417 if tools.contains_key("builtins_enabled") {
418 self.tools.builtins_enabled = layer.builtins_enabled;
419 }
420 if tools.contains_key("shell_enabled") {
421 self.tools.shell_enabled = layer.shell_enabled;
422 }
423 if tools.contains_key("comms_enabled") {
424 self.tools.comms_enabled = layer.comms_enabled;
425 }
426 if tools.contains_key("mob_enabled") {
427 self.tools.mob_enabled = layer.mob_enabled;
428 }
429 if tools.contains_key("schedule_enabled") {
430 self.tools.schedule_enabled = layer.schedule_enabled;
431 }
432 if tools.contains_key("workgraph_enabled") {
433 self.tools.workgraph_enabled = layer.workgraph_enabled;
434 }
435 }
436
437 fn merge_retry_from_toml_presence(&mut self, parsed: &toml::Value, layer: &RetryConfig) {
438 let Some(retry) = parsed.get("retry").and_then(toml::Value::as_table) else {
439 return;
440 };
441 if retry.contains_key("max_retries") {
442 self.retry.max_retries = layer.max_retries;
443 }
444 if retry.contains_key("initial_delay") {
445 self.retry.initial_delay = layer.initial_delay;
446 }
447 if retry.contains_key("max_delay") {
448 self.retry.max_delay = layer.max_delay;
449 }
450 if retry.contains_key("multiplier") {
451 self.retry.multiplier = layer.multiplier;
452 }
453 if retry.contains_key("call_timeout") {
454 self.retry.call_timeout_override = layer.call_timeout_override.clone();
455 }
456 }
457
458 fn merge_provider_tools_from_toml_presence(
459 &mut self,
460 parsed: &toml::Value,
461 layer: &ProviderToolsConfig,
462 ) {
463 let Some(pt) = parsed.get("provider_tools").and_then(toml::Value::as_table) else {
464 return;
465 };
466 if let Some(anthropic) = pt.get("anthropic").and_then(toml::Value::as_table)
467 && anthropic.contains_key("web_search")
468 {
469 self.provider_tools.anthropic.web_search = layer.anthropic.web_search;
470 }
471 if let Some(openai) = pt.get("openai").and_then(toml::Value::as_table)
472 && openai.contains_key("web_search")
473 {
474 self.provider_tools.openai.web_search = layer.openai.web_search;
475 }
476 if let Some(gemini) = pt.get("gemini").and_then(toml::Value::as_table)
477 && gemini.contains_key("google_search")
478 {
479 self.provider_tools.gemini.google_search = layer.gemini.google_search;
480 }
481 }
482
483 fn merge_self_hosted_from_toml_presence(
484 &mut self,
485 parsed: &toml::Value,
486 layer: &SelfHostedConfig,
487 ) {
488 let Some(self_hosted) = parsed.get("self_hosted").and_then(toml::Value::as_table) else {
489 return;
490 };
491
492 if let Some(servers) = self_hosted.get("servers").and_then(toml::Value::as_table) {
493 if servers.is_empty() {
494 self.self_hosted.servers.clear();
495 self.self_hosted.models.clear();
496 } else {
497 let mut merged_servers = self.self_hosted.servers.clone();
498 for (server_id, server_value) in servers {
499 let Some(server_table) = server_value.as_table() else {
500 continue;
501 };
502 let mut merged = self
503 .self_hosted
504 .servers
505 .get(server_id)
506 .cloned()
507 .unwrap_or_default();
508 let Some(server_layer) = layer.servers.get(server_id) else {
509 continue;
510 };
511 if server_table.contains_key("transport") {
512 merged.transport = server_layer.transport;
513 }
514 if server_table.contains_key("base_url") {
515 merged.base_url = server_layer.base_url.clone();
516 }
517 if server_table.contains_key("api_style") {
518 merged.api_style = server_layer.api_style;
519 }
520 if server_table.contains_key("bearer_token") {
521 merged.bearer_token = server_layer.bearer_token.clone();
522 }
523 if server_table.contains_key("bearer_token_env") {
524 merged.bearer_token_env = server_layer.bearer_token_env.clone();
525 }
526 merged_servers.insert(server_id.clone(), merged);
527 }
528 self.self_hosted.servers = merged_servers;
529 }
530 }
531
532 if let Some(models) = self_hosted.get("models").and_then(toml::Value::as_table) {
533 if models.is_empty() {
534 self.self_hosted.models.clear();
535 } else {
536 let mut merged_models = self.self_hosted.models.clone();
537 for (model_id, model_value) in models {
538 let Some(model_table) = model_value.as_table() else {
539 continue;
540 };
541 let mut merged = self
542 .self_hosted
543 .models
544 .get(model_id)
545 .cloned()
546 .unwrap_or_default();
547 let Some(model_layer) = layer.models.get(model_id) else {
548 continue;
549 };
550 if model_table.contains_key("server") {
551 merged.server = model_layer.server.clone();
552 }
553 if model_table.contains_key("remote_model") {
554 merged.remote_model = model_layer.remote_model.clone();
555 }
556 if model_table.contains_key("display_name") {
557 merged.display_name = model_layer.display_name.clone();
558 }
559 if model_table.contains_key("family") {
560 merged.family = model_layer.family.clone();
561 }
562 if model_table.contains_key("tier") {
563 merged.tier = model_layer.tier;
564 }
565 if model_table.contains_key("context_window") {
566 merged.context_window = model_layer.context_window;
567 }
568 if model_table.contains_key("max_output_tokens") {
569 merged.max_output_tokens = model_layer.max_output_tokens;
570 }
571 if model_table.contains_key("vision") {
572 merged.vision = model_layer.vision;
573 }
574 if model_table.contains_key("image_tool_results") {
575 merged.image_tool_results = model_layer.image_tool_results;
576 }
577 if model_table.contains_key("inline_video") {
578 merged.inline_video = model_layer.inline_video;
579 }
580 if model_table.contains_key("supports_temperature") {
581 merged.supports_temperature = model_layer.supports_temperature;
582 }
583 if model_table.contains_key("supports_thinking") {
584 merged.supports_thinking = model_layer.supports_thinking;
585 }
586 if model_table.contains_key("supports_reasoning") {
587 merged.supports_reasoning = model_layer.supports_reasoning;
588 }
589 if model_table.contains_key("call_timeout_secs") {
590 merged.call_timeout_secs = model_layer.call_timeout_secs;
591 }
592 merged_models.insert(model_id.clone(), merged);
593 }
594 self.self_hosted.models = merged_models;
595 }
596 }
597 }
598
599 pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
601 self.apply_env_overrides_from(|key| std::env::var(key).ok())
602 }
603
604 #[doc(hidden)]
609 pub fn apply_env_overrides_from<F>(&mut self, _env: F) -> Result<(), ConfigError>
610 where
611 F: FnMut(&str) -> Option<String>,
612 {
613 Ok(())
622 }
623
624 #[cfg(not(target_arch = "wasm32"))]
629 pub fn apply_cli_overrides(&mut self, cli: CliOverrides) {
630 if let Some(model) = cli.model {
631 self.agent.model = model;
632 }
633 if let Some(tokens) = cli.max_tokens {
634 self.budget.max_tokens = Some(tokens);
635 }
636 if let Some(duration) = cli.max_duration {
637 self.budget.max_duration = Some(duration);
638 }
639 if let Some(calls) = cli.max_tool_calls {
640 self.budget.max_tool_calls = Some(calls);
641 }
642 if let Some(delta) = cli.override_config {
644 let mut value = serde_json::to_value(&self).unwrap_or_default();
645 crate::config_store::merge_patch(&mut value, delta.0);
646 if let Ok(updated) = serde_json::from_value(value) {
647 *self = updated;
648 }
649 }
650 }
651}
652
653impl Config {
654 pub fn validate(&self) -> Result<(), ConfigError> {
661 if self.max_tokens == 0 {
662 return Err(ConfigError::Validation(
663 "max_tokens must be greater than 0".to_string(),
664 ));
665 }
666 if self.agent.max_tokens_per_turn == 0 {
667 return Err(ConfigError::Validation(
668 "agent.max_tokens_per_turn must be greater than 0".to_string(),
669 ));
670 }
671 if self.budget.max_tokens == Some(0) {
672 return Err(ConfigError::Validation(
673 "budget.max_tokens must be greater than 0 when set".to_string(),
674 ));
675 }
676 if self.limits.budget == Some(0) {
677 return Err(ConfigError::Validation(
678 "limits.budget must be greater than 0 when set".to_string(),
679 ));
680 }
681 if self.limits.max_sessions == Some(0) {
682 return Err(ConfigError::Validation(
683 "limits.max_sessions must be greater than 0 when set".to_string(),
684 ));
685 }
686 if self.compaction.auto_compact_threshold == 0 {
687 return Err(ConfigError::Validation(
688 "compaction.auto_compact_threshold must be greater than 0".to_string(),
689 ));
690 }
691 if self.compaction.recent_turn_budget == 0 {
692 return Err(ConfigError::Validation(
693 "compaction.recent_turn_budget must be greater than 0".to_string(),
694 ));
695 }
696 if self.compaction.max_summary_tokens == 0 {
697 return Err(ConfigError::Validation(
698 "compaction.max_summary_tokens must be greater than 0".to_string(),
699 ));
700 }
701 if self.compaction.min_turns_between_compactions == 0 {
702 return Err(ConfigError::Validation(
703 "compaction.min_turns_between_compactions must be greater than 0".to_string(),
704 ));
705 }
706
707 crate::model_registry::ModelRegistry::from_config(self)?;
714
715 Ok(())
716 }
717}
718
719#[derive(Debug, Clone, Default)]
721pub struct CliOverrides {
722 pub model: Option<String>,
723 pub max_tokens: Option<u64>,
724 pub max_duration: Option<Duration>,
725 pub max_tool_calls: Option<usize>,
726 pub override_config: Option<ConfigDelta>,
728}
729
730fn default_structured_output_retries() -> u32 {
731 2
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
736#[serde(default)]
737pub struct AgentConfig {
738 pub system_prompt: Option<String>,
740 pub system_prompt_file: Option<PathBuf>,
742 pub tool_instructions: Option<String>,
744 pub model: String,
746 pub max_tokens_per_turn: u32,
748 pub temperature: Option<f32>,
750 pub budget_warning_threshold: f32,
752 pub max_turns: Option<u32>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub provider_params: Option<serde_json::Value>,
761 #[serde(skip)]
772 pub provider_tool_defaults: Option<serde_json::Value>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub output_schema: Option<OutputSchema>,
781 #[serde(default = "default_structured_output_retries")]
783 pub structured_output_retries: u32,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub extraction_prompt: Option<String>,
791}
792
793impl Default for AgentConfig {
794 fn default() -> Self {
795 let defaults = template_defaults();
796 let agent = defaults.agent.as_ref();
797 Self {
798 system_prompt: None,
799 system_prompt_file: None,
800 tool_instructions: None,
801 model: agent.and_then(|cfg| cfg.model.clone()).unwrap_or_default(),
802 max_tokens_per_turn: agent
803 .and_then(|cfg| cfg.max_tokens_per_turn)
804 .unwrap_or_default(),
805 temperature: None,
806 budget_warning_threshold: agent
807 .and_then(|cfg| cfg.budget_warning_threshold)
808 .unwrap_or_default(),
809 max_turns: None,
810 provider_params: None,
811 provider_tool_defaults: None,
812 output_schema: None,
813 structured_output_retries: default_structured_output_retries(),
814 extraction_prompt: None,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
821#[serde(default)]
822pub struct PresentationConfig {
823 pub html: HtmlPresentationConfig,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
831#[serde(default)]
832pub struct HtmlPresentationConfig {
833 pub default_template: String,
834 pub templates: BTreeMap<String, HtmlTemplateConfig>,
835}
836
837impl Default for HtmlPresentationConfig {
838 fn default() -> Self {
839 Self {
840 default_template: "polished".to_string(),
841 templates: BTreeMap::new(),
842 }
843 }
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
848#[serde(default)]
849pub struct HtmlTemplateConfig {
850 pub path: Option<PathBuf>,
851 pub body: Option<String>,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
856#[serde(default)]
857pub struct ModelDefaults {
858 pub anthropic: String,
859 pub openai: String,
860 pub gemini: String,
861}
862
863impl Default for ModelDefaults {
864 fn default() -> Self {
865 Self {
866 anthropic: crate::model_profile::catalog::default_model("anthropic")
867 .unwrap_or_default()
868 .to_string(),
869 openai: crate::model_profile::catalog::default_model("openai")
870 .unwrap_or_default()
871 .to_string(),
872 gemini: crate::model_profile::catalog::default_model("gemini")
873 .unwrap_or_default()
874 .to_string(),
875 }
876 }
877}
878
879pub const DEFAULT_SHELL_PROGRAM: &str = "nu";
881pub const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 30;
883pub const DEFAULT_SHELL_SECURITY_MODE: SecurityMode = SecurityMode::Unrestricted;
885
886#[derive(Debug, Clone, Serialize, PartialEq)]
888#[serde(default)]
889pub struct ShellDefaults {
890 pub program: String,
891 pub timeout_secs: u64,
892 pub security_mode: SecurityMode,
894 pub security_patterns: Vec<String>,
896}
897
898#[derive(Debug, Deserialize, Default)]
899#[serde(default)]
900struct ShellDefaultsSeed {
901 program: Option<String>,
902 timeout_secs: Option<u64>,
903 security_mode: Option<SecurityMode>,
904 security_patterns: Option<Vec<String>>,
905 #[serde(alias = "allowlist")]
906 allowlist: Option<Vec<String>>,
907}
908
909impl<'de> Deserialize<'de> for ShellDefaults {
910 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
911 where
912 D: Deserializer<'de>,
913 {
914 let seed = ShellDefaultsSeed::deserialize(deserializer)?;
915 let mut defaults = ShellDefaults::default();
916
917 if let Some(program) = seed.program {
918 defaults.program = program;
919 }
920 if let Some(timeout_secs) = seed.timeout_secs {
921 defaults.timeout_secs = timeout_secs;
922 }
923 if let Some(security_mode) = seed.security_mode {
924 defaults.security_mode = security_mode;
925 }
926 if let Some(security_patterns) = seed.security_patterns.or(seed.allowlist.clone()) {
927 defaults.security_patterns = security_patterns;
928 }
929
930 if seed.security_mode.is_none() && seed.allowlist.is_some() {
931 defaults.security_mode = SecurityMode::AllowList;
932 }
933
934 Ok(defaults)
935 }
936}
937
938impl Default for ShellDefaults {
939 fn default() -> Self {
940 let defaults = template_defaults();
941 let shell = defaults.shell.as_ref();
942 Self {
943 program: shell
944 .and_then(|cfg| cfg.program.clone())
945 .unwrap_or_else(|| DEFAULT_SHELL_PROGRAM.to_string()),
946 timeout_secs: shell
947 .and_then(|cfg| cfg.timeout_secs)
948 .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS),
949 security_mode: shell
950 .and_then(|cfg| cfg.security_mode)
951 .unwrap_or(DEFAULT_SHELL_SECURITY_MODE),
952 security_patterns: shell
953 .and_then(|cfg| cfg.security_patterns.clone())
954 .unwrap_or_default(),
955 }
956 }
957}
958
959const CONFIG_TEMPLATE_TOML: &str = include_str!("config_template.toml");
960
961#[derive(Debug, Deserialize)]
962struct TemplateAgentDefaults {
963 model: Option<String>,
964 max_tokens_per_turn: Option<u32>,
965 budget_warning_threshold: Option<f32>,
966}
967
968#[derive(Debug, Deserialize)]
969struct TemplateShellDefaults {
970 program: Option<String>,
971 timeout_secs: Option<u64>,
972 security_mode: Option<SecurityMode>,
973 security_patterns: Option<Vec<String>>,
974}
975
976#[derive(Debug, Deserialize)]
977struct TemplateDefaults {
978 agent: Option<TemplateAgentDefaults>,
979 shell: Option<TemplateShellDefaults>,
980 max_tokens: Option<u32>,
981}
982
983impl TemplateDefaults {
984 fn empty() -> Self {
985 Self {
986 agent: None,
987 shell: None,
988 max_tokens: None,
989 }
990 }
991}
992
993fn template_defaults() -> &'static TemplateDefaults {
994 static DEFAULTS: OnceLock<TemplateDefaults> = OnceLock::new();
995 DEFAULTS.get_or_init(|| {
996 toml::from_str(CONFIG_TEMPLATE_TOML).unwrap_or_else(|e| {
997 tracing::error!("Invalid config template defaults: {}", e);
1000 TemplateDefaults::empty()
1001 })
1002 })
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1007#[serde(default)]
1008pub struct StoreConfig {
1009 pub sessions_path: Option<PathBuf>,
1010 pub tasks_path: Option<PathBuf>,
1011 pub database_dir: Option<PathBuf>,
1013}
1014
1015#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1021#[serde(rename_all = "snake_case")]
1022pub enum SelfHostedTransport {
1023 #[default]
1024 #[serde(alias = "openai_compatible")]
1025 OpenAiCompatible,
1026}
1027
1028#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1029#[serde(rename_all = "snake_case")]
1030pub enum SelfHostedApiStyle {
1031 Responses,
1032 #[default]
1033 ChatCompletions,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1037#[serde(default)]
1038pub struct SelfHostedServerConfig {
1039 pub transport: SelfHostedTransport,
1040 pub base_url: String,
1041 pub api_style: SelfHostedApiStyle,
1042 #[serde(default, skip_serializing)]
1043 pub bearer_token: Option<String>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub bearer_token_env: Option<String>,
1046}
1047
1048impl Default for SelfHostedServerConfig {
1049 fn default() -> Self {
1050 Self {
1051 transport: SelfHostedTransport::OpenAiCompatible,
1052 base_url: String::new(),
1053 api_style: SelfHostedApiStyle::ChatCompletions,
1054 bearer_token: None,
1055 bearer_token_env: None,
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1061#[serde(default)]
1062pub struct SelfHostedModelConfig {
1063 pub server: String,
1064 pub remote_model: String,
1065 pub display_name: String,
1066 pub family: String,
1067 pub tier: ModelTier,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub context_window: Option<u32>,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub max_output_tokens: Option<u32>,
1072 pub vision: bool,
1073 pub image_tool_results: bool,
1074 pub inline_video: bool,
1075 pub supports_temperature: bool,
1076 pub supports_thinking: bool,
1077 pub supports_reasoning: bool,
1078 #[serde(default)]
1080 pub supports_web_search: bool,
1081 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub call_timeout_secs: Option<u64>,
1083}
1084
1085impl Default for SelfHostedModelConfig {
1086 fn default() -> Self {
1087 Self {
1088 server: String::new(),
1089 remote_model: String::new(),
1090 display_name: String::new(),
1091 family: String::new(),
1092 tier: ModelTier::Supported,
1093 context_window: None,
1094 max_output_tokens: None,
1095 vision: false,
1096 image_tool_results: false,
1097 inline_video: false,
1098 supports_temperature: true,
1099 supports_thinking: false,
1100 supports_reasoning: false,
1101 supports_web_search: false,
1102 call_timeout_secs: None,
1103 }
1104 }
1105}
1106
1107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1108#[serde(default)]
1109pub struct SelfHostedConfig {
1110 pub servers: BTreeMap<String, SelfHostedServerConfig>,
1111 pub models: BTreeMap<String, SelfHostedModelConfig>,
1112}
1113
1114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1125#[serde(default)]
1126pub struct ProviderToolsConfig {
1127 pub anthropic: AnthropicProviderToolsConfig,
1128 pub openai: OpenAiProviderToolsConfig,
1129 pub gemini: GeminiProviderToolsConfig,
1130}
1131
1132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1134#[serde(default)]
1135pub struct AnthropicProviderToolsConfig {
1136 pub web_search: bool,
1138}
1139
1140impl Default for AnthropicProviderToolsConfig {
1141 fn default() -> Self {
1142 Self { web_search: true }
1143 }
1144}
1145
1146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1148#[serde(default)]
1149pub struct OpenAiProviderToolsConfig {
1150 pub web_search: bool,
1152}
1153
1154impl Default for OpenAiProviderToolsConfig {
1155 fn default() -> Self {
1156 Self { web_search: true }
1157 }
1158}
1159
1160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1162#[serde(default)]
1163pub struct GeminiProviderToolsConfig {
1164 pub google_search: bool,
1166}
1167
1168impl Default for GeminiProviderToolsConfig {
1169 fn default() -> Self {
1170 Self {
1171 google_search: true,
1172 }
1173 }
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1178#[serde(default)]
1179pub struct LimitsConfig {
1180 pub budget: Option<u64>,
1181 pub max_sessions: Option<usize>,
1185 #[serde(with = "optional_duration_serde")]
1186 pub max_duration: Option<Duration>,
1187}
1188
1189impl LimitsConfig {
1190 pub fn to_budget_limits(&self) -> BudgetLimits {
1191 BudgetLimits {
1192 max_tokens: self.budget,
1193 max_duration: self.max_duration,
1194 max_tool_calls: None,
1195 }
1196 }
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1201#[serde(default)]
1202pub struct RestServerConfig {
1203 pub host: String,
1204 pub port: u16,
1205}
1206
1207impl Default for RestServerConfig {
1208 fn default() -> Self {
1209 Self {
1210 host: "127.0.0.1".to_string(),
1211 port: 8080,
1212 }
1213 }
1214}
1215
1216#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
1226#[serde(rename_all = "snake_case")]
1227pub enum CommsAuthMode {
1228 #[default]
1229 #[serde(rename = "none")]
1230 Open,
1231 Ed25519,
1232}
1233
1234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1239#[serde(rename_all = "snake_case")]
1240pub enum PlainEventSource {
1241 Tcp,
1242 Uds,
1243 Stdin,
1244 Webhook,
1245 Rpc,
1246}
1247
1248impl std::fmt::Display for PlainEventSource {
1249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1250 match self {
1251 Self::Tcp => write!(f, "tcp"),
1252 Self::Uds => write!(f, "uds"),
1253 Self::Stdin => write!(f, "stdin"),
1254 Self::Webhook => write!(f, "webhook"),
1255 Self::Rpc => write!(f, "rpc"),
1256 }
1257 }
1258}
1259
1260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1262#[serde(default)]
1263pub struct CommsRuntimeConfig {
1264 pub mode: CommsRuntimeMode,
1265 pub address: Option<String>,
1267 pub auth: CommsAuthMode,
1268 pub require_peer_auth: bool,
1274 pub event_address: Option<String>,
1277}
1278
1279impl Default for CommsRuntimeConfig {
1280 fn default() -> Self {
1281 Self {
1282 mode: CommsRuntimeMode::Inproc,
1283 address: None,
1284 auth: CommsAuthMode::default(),
1285 require_peer_auth: true,
1286 event_address: None,
1287 }
1288 }
1289}
1290
1291#[derive(Debug, Clone, PartialEq)]
1296pub struct CompactionRuntimeConfig {
1297 pub auto_compact_threshold: u64,
1299 pub auto_compact_threshold_explicit: bool,
1304 pub recent_turn_budget: usize,
1306 pub max_summary_tokens: u32,
1308 pub min_turns_between_compactions: u32,
1310}
1311
1312impl Default for CompactionRuntimeConfig {
1313 fn default() -> Self {
1314 Self {
1315 auto_compact_threshold: 100_000,
1316 auto_compact_threshold_explicit: false,
1317 recent_turn_budget: 4,
1318 max_summary_tokens: 4096,
1319 min_turns_between_compactions: 3,
1320 }
1321 }
1322}
1323
1324impl Serialize for CompactionRuntimeConfig {
1325 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1326 where
1327 S: serde::Serializer,
1328 {
1329 let defaults = Self::default();
1330 let include_threshold = self.auto_compact_threshold_explicit
1331 || self.auto_compact_threshold != defaults.auto_compact_threshold;
1332 let mut len = 3;
1333 if include_threshold {
1334 len += 1;
1335 }
1336
1337 let mut state = serializer.serialize_struct("CompactionRuntimeConfig", len)?;
1338 if include_threshold {
1339 state.serialize_field("auto_compact_threshold", &self.auto_compact_threshold)?;
1340 }
1341 state.serialize_field("recent_turn_budget", &self.recent_turn_budget)?;
1342 state.serialize_field("max_summary_tokens", &self.max_summary_tokens)?;
1343 state.serialize_field(
1344 "min_turns_between_compactions",
1345 &self.min_turns_between_compactions,
1346 )?;
1347 state.end()
1348 }
1349}
1350
1351impl<'de> Deserialize<'de> for CompactionRuntimeConfig {
1352 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1353 where
1354 D: Deserializer<'de>,
1355 {
1356 #[derive(Deserialize)]
1357 struct Seed {
1358 auto_compact_threshold: Option<u64>,
1359 recent_turn_budget: Option<usize>,
1360 max_summary_tokens: Option<u32>,
1361 min_turns_between_compactions: Option<u32>,
1362 }
1363
1364 let seed = Seed::deserialize(deserializer)?;
1365 let defaults = Self::default();
1366 Ok(Self {
1367 auto_compact_threshold: seed
1368 .auto_compact_threshold
1369 .unwrap_or(defaults.auto_compact_threshold),
1370 auto_compact_threshold_explicit: seed.auto_compact_threshold.is_some(),
1371 recent_turn_budget: seed
1372 .recent_turn_budget
1373 .unwrap_or(defaults.recent_turn_budget),
1374 max_summary_tokens: seed
1375 .max_summary_tokens
1376 .unwrap_or(defaults.max_summary_tokens),
1377 min_turns_between_compactions: seed
1378 .min_turns_between_compactions
1379 .unwrap_or(defaults.min_turns_between_compactions),
1380 })
1381 }
1382}
1383
1384impl From<CompactionRuntimeConfig> for crate::CompactionConfig {
1385 fn from(value: CompactionRuntimeConfig) -> Self {
1386 Self {
1387 auto_compact_threshold: value.auto_compact_threshold,
1388 recent_turn_budget: value.recent_turn_budget,
1389 max_summary_tokens: value.max_summary_tokens,
1390 min_turns_between_compactions: value.min_turns_between_compactions,
1391 }
1392 }
1393}
1394
1395#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
1397#[serde(rename_all = "snake_case")]
1398pub enum CommsRuntimeMode {
1399 #[default]
1400 Inproc,
1401 Tcp,
1402 Uds,
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize)]
1419#[serde(default)]
1420pub struct StorageConfig {
1421 pub directory: Option<PathBuf>,
1423}
1424
1425impl Default for StorageConfig {
1426 fn default() -> Self {
1427 Self {
1428 directory: data_dir().map(|d| d.join("sessions")),
1429 }
1430 }
1431}
1432
1433#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1435#[serde(default)]
1436pub struct BudgetConfig {
1437 pub max_tokens: Option<u64>,
1439 #[serde(with = "optional_duration_serde")]
1441 pub max_duration: Option<Duration>,
1442 pub max_tool_calls: Option<usize>,
1444}
1445
1446#[derive(Debug, Clone, Default, PartialEq, Eq)]
1458#[non_exhaustive]
1459pub enum CallTimeoutOverride {
1460 #[default]
1462 Inherit,
1463 Disabled,
1465 Value(Duration),
1467}
1468
1469impl Serialize for CallTimeoutOverride {
1470 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1471 match self {
1472 Self::Inherit => serializer.serialize_none(),
1474 Self::Disabled => serializer.serialize_str("disabled"),
1475 Self::Value(d) => {
1476 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1477 serializer.serialize_str(&s)
1478 }
1479 }
1480 }
1481}
1482
1483impl<'de> Deserialize<'de> for CallTimeoutOverride {
1484 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1485 let s = String::deserialize(deserializer)?;
1486 if s == "disabled" {
1487 return Ok(Self::Disabled);
1488 }
1489 let d: Duration = s
1490 .parse::<humantime_serde::re::humantime::Duration>()
1491 .map(|ht| *ht)
1492 .map_err(serde::de::Error::custom)?;
1493 Ok(Self::Value(d))
1494 }
1495}
1496
1497impl CallTimeoutOverride {
1498 pub fn is_inherit(&self) -> bool {
1500 matches!(self, Self::Inherit)
1501 }
1502}
1503
1504#[derive(Debug, Clone, Serialize, Deserialize)]
1506#[serde(default)]
1507pub struct RetryConfig {
1508 pub max_retries: u32,
1510 #[serde(with = "humantime_serde")]
1512 pub initial_delay: Duration,
1513 #[serde(with = "humantime_serde")]
1515 pub max_delay: Duration,
1516 pub multiplier: f64,
1518 #[serde(
1524 default,
1525 rename = "call_timeout",
1526 skip_serializing_if = "CallTimeoutOverride::is_inherit"
1527 )]
1528 pub call_timeout_override: CallTimeoutOverride,
1529}
1530
1531impl Default for RetryConfig {
1532 fn default() -> Self {
1533 let policy = RetryPolicy::default();
1534 Self {
1535 max_retries: policy.max_retries,
1536 initial_delay: policy.initial_delay,
1537 max_delay: policy.max_delay,
1538 multiplier: policy.multiplier,
1539 call_timeout_override: CallTimeoutOverride::default(),
1540 }
1541 }
1542}
1543
1544impl From<RetryConfig> for RetryPolicy {
1545 fn from(config: RetryConfig) -> Self {
1546 let call_timeout = match config.call_timeout_override {
1549 CallTimeoutOverride::Inherit => None,
1550 CallTimeoutOverride::Disabled => None,
1551 CallTimeoutOverride::Value(d) => Some(d),
1552 };
1553 RetryPolicy {
1554 max_retries: config.max_retries,
1555 initial_delay: config.initial_delay,
1556 max_delay: config.max_delay,
1557 multiplier: config.multiplier,
1558 call_timeout,
1559 }
1560 }
1561}
1562
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1565#[serde(default)]
1566pub struct ToolsConfig {
1567 #[serde(default)]
1569 pub mcp_servers: Vec<McpServerConfig>,
1570 #[serde(with = "humantime_serde")]
1572 pub default_timeout: Duration,
1573 #[serde(default)]
1575 pub tool_timeouts: HashMap<String, Duration>,
1576 pub max_concurrent: usize,
1578 pub builtins_enabled: bool,
1580 pub shell_enabled: bool,
1582 pub comms_enabled: bool,
1584 pub mob_enabled: bool,
1586 pub schedule_enabled: bool,
1588 pub workgraph_enabled: bool,
1590}
1591
1592impl Default for ToolsConfig {
1593 fn default() -> Self {
1594 Self {
1595 mcp_servers: Vec::new(),
1596 default_timeout: Duration::from_secs(600),
1597 tool_timeouts: HashMap::new(),
1598 max_concurrent: 10,
1599 builtins_enabled: false,
1600 shell_enabled: false,
1601 comms_enabled: false,
1602 mob_enabled: false,
1603 schedule_enabled: true,
1604 workgraph_enabled: false,
1605 }
1606 }
1607}
1608
1609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1611#[serde(default)]
1612pub struct HooksConfig {
1613 pub default_timeout_ms: u64,
1615 pub payload_max_bytes: usize,
1617 pub background_max_concurrency: usize,
1619 #[serde(default)]
1621 pub entries: Vec<HookEntryConfig>,
1622}
1623
1624impl HooksConfig {
1625 pub fn append_entries_from(&mut self, other: &HooksConfig) {
1626 self.entries.extend(other.entries.clone());
1627 }
1628}
1629
1630impl Default for HooksConfig {
1631 fn default() -> Self {
1632 Self {
1633 default_timeout_ms: 5_000,
1634 payload_max_bytes: 128 * 1024,
1635 background_max_concurrency: 32,
1636 entries: Vec::new(),
1637 }
1638 }
1639}
1640
1641#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1643#[serde(default)]
1644pub struct HookRunOverrides {
1645 #[serde(default)]
1647 pub entries: Vec<HookEntryConfig>,
1648 #[serde(default)]
1650 pub disable: Vec<HookId>,
1651}
1652
1653#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1655#[serde(default)]
1656pub struct HookEntryConfig {
1657 pub id: HookId,
1658 pub enabled: bool,
1659 pub point: HookPoint,
1660 pub mode: HookExecutionMode,
1661 pub capability: HookCapability,
1662 pub priority: i32,
1663 #[serde(default, skip_serializing_if = "Option::is_none")]
1664 pub failure_policy: Option<HookFailurePolicy>,
1665 #[serde(default, skip_serializing_if = "Option::is_none")]
1666 pub timeout_ms: Option<u64>,
1667 pub runtime: HookRuntimeConfig,
1668}
1669
1670impl HookEntryConfig {
1671 pub fn effective_failure_policy(&self) -> HookFailurePolicy {
1677 self.failure_policy
1678 .unwrap_or_else(|| crate::hooks::default_failure_policy(self.capability))
1679 }
1680}
1681
1682impl Default for HookEntryConfig {
1683 fn default() -> Self {
1684 Self {
1685 id: HookId::new("hook"),
1686 enabled: true,
1687 point: HookPoint::TurnBoundary,
1688 mode: HookExecutionMode::Foreground,
1689 capability: HookCapability::Observe,
1690 priority: 100,
1691 failure_policy: None,
1692 timeout_ms: None,
1693 runtime: HookRuntimeConfig::in_process("noop").unwrap_or(HookRuntimeConfig {
1694 kind: HookRuntimeKind::InProcess,
1695 config: None,
1696 }),
1697 }
1698 }
1699}
1700
1701#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
1703#[serde(transparent)]
1704pub struct HookInProcessHandlerId(String);
1705
1706impl HookInProcessHandlerId {
1707 pub fn new(value: impl Into<String>) -> Self {
1708 Self(value.into())
1709 }
1710
1711 pub fn as_str(&self) -> &str {
1712 &self.0
1713 }
1714}
1715
1716impl std::fmt::Display for HookInProcessHandlerId {
1717 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1718 self.0.fmt(f)
1719 }
1720}
1721
1722impl From<&str> for HookInProcessHandlerId {
1723 fn from(value: &str) -> Self {
1724 Self::new(value)
1725 }
1726}
1727
1728impl From<String> for HookInProcessHandlerId {
1729 fn from(value: String) -> Self {
1730 Self::new(value)
1731 }
1732}
1733
1734impl Default for HookInProcessHandlerId {
1735 fn default() -> Self {
1736 Self::new("noop")
1737 }
1738}
1739
1740#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1745#[serde(default)]
1746pub struct HookInProcessRuntimeConfig {
1747 #[serde(alias = "name")]
1748 pub handler: HookInProcessHandlerId,
1749}
1750
1751impl HookInProcessRuntimeConfig {
1752 pub fn new(handler: impl Into<HookInProcessHandlerId>) -> Self {
1753 Self {
1754 handler: handler.into(),
1755 }
1756 }
1757}
1758
1759impl Default for HookInProcessRuntimeConfig {
1760 fn default() -> Self {
1761 Self::new("noop")
1762 }
1763}
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1770pub enum HookRuntimeKind {
1771 InProcess,
1772 Command,
1773 Http,
1774}
1775
1776impl HookRuntimeKind {
1777 pub fn as_str(&self) -> &'static str {
1779 match self {
1780 Self::InProcess => "in_process",
1781 Self::Command => "command",
1782 Self::Http => "http",
1783 }
1784 }
1785
1786 pub fn parse(s: &str) -> Option<Self> {
1788 match s {
1789 "in_process" => Some(Self::InProcess),
1790 "command" => Some(Self::Command),
1791 "http" => Some(Self::Http),
1792 _ => None,
1793 }
1794 }
1795}
1796
1797impl std::fmt::Display for HookRuntimeKind {
1798 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1799 f.write_str(self.as_str())
1800 }
1801}
1802
1803#[derive(Debug, Clone)]
1809pub struct HookRuntimeConfig {
1810 pub kind: HookRuntimeKind,
1811 #[allow(clippy::box_collection)]
1812 pub config: Option<Box<RawValue>>,
1813}
1814
1815impl PartialEq for HookRuntimeConfig {
1816 fn eq(&self, other: &Self) -> bool {
1817 self.kind == other.kind
1818 && self.config.as_ref().map(|raw| raw.get())
1819 == other.config.as_ref().map(|raw| raw.get())
1820 }
1821}
1822
1823impl HookRuntimeConfig {
1824 pub fn new(kind: HookRuntimeKind, config: Option<Value>) -> Result<Self, serde_json::Error> {
1825 let config = match config {
1826 Some(value) => Some(raw_json_from_value(value)?),
1827 None => None,
1828 };
1829 Ok(Self { kind, config })
1830 }
1831
1832 pub fn in_process(
1833 handler: impl Into<HookInProcessHandlerId>,
1834 ) -> Result<Self, serde_json::Error> {
1835 serde_json::to_value(HookInProcessRuntimeConfig::new(handler))
1836 .and_then(|config| Self::new(HookRuntimeKind::InProcess, Some(config)))
1837 }
1838
1839 pub fn in_process_config(
1840 &self,
1841 ) -> Result<Option<HookInProcessRuntimeConfig>, serde_json::Error> {
1842 if self.kind != HookRuntimeKind::InProcess {
1843 return Ok(None);
1844 }
1845
1846 self.config_value()
1847 .and_then(serde_json::from_value::<HookInProcessRuntimeConfig>)
1848 .map(Some)
1849 }
1850
1851 pub fn config_value(&self) -> Result<Value, serde_json::Error> {
1852 match &self.config {
1853 Some(raw) => serde_json::from_str(raw.get()),
1854 None => Ok(Value::Null),
1855 }
1856 }
1857}
1858
1859impl Default for HookRuntimeConfig {
1860 fn default() -> Self {
1861 Self::in_process("noop").unwrap_or(Self {
1862 kind: HookRuntimeKind::InProcess,
1863 config: None,
1864 })
1865 }
1866}
1867
1868impl Serialize for HookRuntimeConfig {
1869 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1870 where
1871 S: serde::Serializer,
1872 {
1873 let mut map = Map::new();
1874 map.insert(
1875 "type".to_string(),
1876 Value::String(self.kind.as_str().to_string()),
1877 );
1878
1879 if let Some(raw) = &self.config {
1880 let parsed: Value =
1881 serde_json::from_str(raw.get()).map_err(serde::ser::Error::custom)?;
1882 match parsed {
1883 Value::Object(obj) => {
1884 for (key, value) in obj {
1885 map.insert(key, value);
1886 }
1887 }
1888 other => {
1889 map.insert("config".to_string(), other);
1890 }
1891 }
1892 }
1893
1894 Value::Object(map).serialize(serializer)
1895 }
1896}
1897
1898impl<'de> Deserialize<'de> for HookRuntimeConfig {
1899 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1900 where
1901 D: serde::Deserializer<'de>,
1902 {
1903 let value = Value::deserialize(deserializer)?;
1904 let mut obj = value
1905 .as_object()
1906 .cloned()
1907 .ok_or_else(|| serde::de::Error::custom("hook runtime must be an object"))?;
1908
1909 let kind_str = obj
1910 .remove("type")
1911 .and_then(|value| value.as_str().map(ToOwned::to_owned))
1912 .ok_or_else(|| {
1913 serde::de::Error::custom("hook runtime missing required field 'type'")
1914 })?;
1915 let kind = HookRuntimeKind::parse(&kind_str).ok_or_else(|| {
1916 serde::de::Error::custom(format!("unsupported hook runtime '{kind_str}'"))
1917 })?;
1918
1919 let config_value = if let Some(explicit) = obj.remove("config") {
1920 if obj.is_empty() {
1921 explicit
1922 } else {
1923 obj.insert("config".to_string(), explicit);
1924 Value::Object(obj)
1925 }
1926 } else if obj.is_empty() {
1927 Value::Null
1928 } else {
1929 Value::Object(obj)
1930 };
1931
1932 let config = if config_value.is_null() {
1933 None
1934 } else {
1935 Some(raw_json_from_value(config_value).map_err(serde::de::Error::custom)?)
1936 };
1937
1938 Ok(Self { kind, config })
1939 }
1940}
1941
1942impl JsonSchema for HookRuntimeConfig {
1943 fn schema_name() -> std::borrow::Cow<'static, str> {
1944 "HookRuntimeConfig".into()
1945 }
1946
1947 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1948 schemars::json_schema!({
1949 "type": "object",
1950 "required": ["type"],
1951 "properties": {
1952 "type": { "type": "string" },
1953 "handler": { "type": "string" },
1954 "name": { "type": "string", "deprecated": true },
1955 "config": {}
1956 },
1957 "additionalProperties": true
1958 })
1959 }
1960}
1961
1962fn raw_json_from_value(value: Value) -> Result<Box<RawValue>, serde_json::Error> {
1963 RawValue::from_string(serde_json::to_string(&value)?)
1964}
1965
1966#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1968#[serde(rename_all = "snake_case")]
1969pub enum ConfigScope {
1970 Global,
1971 Project,
1972}
1973
1974#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1976#[serde(transparent)]
1977pub struct ConfigDelta(pub serde_json::Value);
1978
1979#[derive(Debug, thiserror::Error)]
1981pub enum ConfigError {
1982 #[error("IO error: {0}")]
1983 Io(#[from] std::io::Error),
1984
1985 #[error("Parse error: {0}")]
1986 Parse(#[from] toml::de::Error),
1987
1988 #[error("TOML serialization error: {0}")]
1989 TomlSerialize(#[from] toml::ser::Error),
1990
1991 #[error("JSON error: {0}")]
1992 Json(#[from] serde_json::Error),
1993
1994 #[error("UTF-8 error: {0}")]
1995 Utf8(#[from] std::string::FromUtf8Error),
1996
1997 #[allow(dead_code)]
1998 #[error("Invalid value for {0}")]
1999 InvalidValue(String),
2000
2001 #[error("Missing required field: {0}")]
2002 MissingField(String),
2003
2004 #[error("Internal error: {0}")]
2005 InternalError(String),
2006
2007 #[error("Validation error: {0}")]
2008 Validation(String),
2009}
2010
2011mod optional_duration_serde {
2013 use serde::{Deserialize, Deserializer, Serialize, Serializer};
2014 use std::time::Duration;
2015
2016 #[allow(clippy::ref_option)]
2019 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
2020 where
2021 S: Serializer,
2022 {
2023 match duration {
2024 Some(d) => {
2025 let s = humantime_serde::re::humantime::format_duration(*d).to_string();
2026 s.serialize(serializer)
2027 }
2028 None => serializer.serialize_none(),
2029 }
2030 }
2031
2032 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
2033 where
2034 D: Deserializer<'de>,
2035 {
2036 use serde::de::Error;
2037
2038 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
2040 match value {
2041 None => Ok(None),
2042 Some(serde_json::Value::String(s)) => {
2043 humantime_serde::re::humantime::parse_duration(&s)
2044 .map(Some)
2045 .map_err(|e| D::Error::custom(e.to_string()))
2046 }
2047 Some(serde_json::Value::Number(n)) => {
2048 let millis = n
2050 .as_u64()
2051 .ok_or_else(|| D::Error::custom("invalid number"))?;
2052 Ok(Some(Duration::from_millis(millis)))
2053 }
2054 _ => Err(D::Error::custom("expected string or number for duration")),
2055 }
2056 }
2057}
2058
2059pub fn find_project_root(start_dir: &std::path::Path) -> Option<PathBuf> {
2061 let mut current = start_dir.to_path_buf();
2062 loop {
2063 if current.join(".rkat").is_dir() {
2064 return Some(current);
2065 }
2066 if !current.pop() {
2067 return None;
2068 }
2069 }
2070}
2071
2072pub fn data_dir() -> Option<PathBuf> {
2078 if let Ok(cwd) = std::env::current_dir()
2080 && let Some(root) = find_project_root(&cwd)
2081 {
2082 return Some(root.join(".rkat"));
2083 }
2084
2085 dirs::home_dir().map(|h| h.join(".rkat"))
2087}
2088
2089pub mod dirs {
2091 use std::path::PathBuf;
2092
2093 pub fn home_dir() -> Option<PathBuf> {
2094 std::env::var_os("HOME").map(PathBuf::from)
2095 }
2096}
2097
2098#[cfg(test)]
2099#[allow(clippy::unwrap_used, clippy::expect_used)]
2100mod tests {
2101 use super::*;
2102 use crate::Provider;
2103
2104 #[test]
2105 fn test_config_default() {
2106 let config = Config::default();
2107 assert_eq!(config.agent.model, "gpt-5.5");
2108 assert_eq!(config.agent.max_tokens_per_turn, 16384);
2109 assert_eq!(config.retry.max_retries, 3);
2110 assert_eq!(config.max_sessions(), DEFAULT_MAX_SESSIONS);
2111 }
2112
2113 #[test]
2114 fn config_template_agent_model_tracks_openai_catalog_default() {
2115 let config = Config::template().expect("template parses");
2116 assert_eq!(
2117 config.agent.model.as_str(),
2118 crate::model_profile::catalog::default_model("openai").expect("openai catalog default")
2119 );
2120 }
2121
2122 #[test]
2123 fn test_limits_max_sessions_configures_runtime_capacity() {
2124 let mut config = Config::default();
2125 config
2126 .merge_toml_str(
2127 r"
2128[limits]
2129max_sessions = 7
2130",
2131 )
2132 .expect("merge max_sessions");
2133 assert_eq!(config.max_sessions(), 7);
2134 }
2135
2136 #[test]
2137 fn test_config_layering() {
2138 let config = Config::default();
2140 assert_eq!(config.agent.model, "gpt-5.5");
2141 assert_eq!(config.budget.max_tokens, None);
2142
2143 {
2145 let env = std::collections::HashMap::from([(
2151 "RKAT_MODEL".to_string(),
2152 "env-model".to_string(),
2153 )]);
2154 let mut config = Config::default();
2155 config
2156 .apply_env_overrides_from(|key| env.get(key).cloned())
2157 .expect("apply env overrides");
2158 }
2159
2160 let mut config = Config::default();
2162 let file_config = Config {
2163 agent: AgentConfig {
2164 model: "file-model".to_string(),
2165 ..Default::default()
2166 },
2167 ..Default::default()
2168 };
2169 config.merge(file_config);
2170 assert_eq!(config.agent.model, "file-model");
2171
2172 let mut config = Config::default();
2174 config.apply_cli_overrides(CliOverrides {
2175 model: Some("cli-model".to_string()),
2176 max_tokens: Some(50000),
2177 ..Default::default()
2178 });
2179 assert_eq!(config.agent.model, "cli-model");
2181 assert_eq!(config.budget.max_tokens, Some(50000));
2182 }
2183
2184 #[test]
2185 fn test_merge_extraction_prompt_survives_layering() {
2186 let mut base = Config::default();
2187 assert!(base.agent.extraction_prompt.is_none());
2188
2189 let toml = r#"
2191[agent]
2192extraction_prompt = "Return JSON only."
2193"#;
2194 base.merge_toml_str(toml).expect("merge toml");
2195 assert_eq!(
2196 base.agent.extraction_prompt.as_deref(),
2197 Some("Return JSON only.")
2198 );
2199
2200 let toml2 = r#"
2202[agent]
2203model = "custom-model"
2204"#;
2205 base.merge_toml_str(toml2).expect("merge toml2");
2206 assert_eq!(
2207 base.agent.extraction_prompt.as_deref(),
2208 Some("Return JSON only."),
2209 "extraction_prompt must survive merge when absent in later layer"
2210 );
2211 assert_eq!(base.agent.model, "custom-model");
2212 }
2213
2214 #[test]
2215 fn test_merge_hooks_entries_append() {
2216 let mut base = Config::default();
2217 let base_entry = HookEntryConfig {
2218 id: HookId::new("base"),
2219 ..HookEntryConfig::default()
2220 };
2221 base.hooks.entries.push(base_entry);
2222
2223 let mut other = Config::default();
2224 let other_entry = HookEntryConfig {
2225 id: HookId::new("other"),
2226 ..HookEntryConfig::default()
2227 };
2228 other.hooks.entries.push(other_entry);
2229
2230 base.merge(other);
2231 let ids = base
2232 .hooks
2233 .entries
2234 .iter()
2235 .map(|entry| entry.id.0.as_str())
2236 .collect::<Vec<_>>();
2237 assert_eq!(ids, vec!["base", "other"]);
2238 }
2239
2240 #[test]
2241 fn test_merge_self_hosted_preserves_lower_layer_servers_and_models() {
2242 let mut base = Config::default();
2243 base.merge_toml_str(
2244 r#"
2245[self_hosted.servers.local]
2246base_url = "http://127.0.0.1:11434"
2247"#,
2248 )
2249 .expect("base self-hosted server");
2250 base.merge_toml_str(
2251 r#"
2252[self_hosted.models.gemma-4-e2b]
2253server = "local"
2254remote_model = "gemma4:e2b"
2255display_name = "Gemma 4 E2B"
2256family = "gemma-4"
2257"#,
2258 )
2259 .expect("overlay self-hosted model");
2260
2261 assert!(base.self_hosted.servers.contains_key("local"));
2262 assert!(base.self_hosted.models.contains_key("gemma-4-e2b"));
2263 let registry = base.model_registry().expect("merged self-hosted registry");
2264 assert_eq!(
2265 registry
2266 .entry("gemma-4-e2b")
2267 .and_then(|entry| entry.self_hosted.as_ref())
2268 .map(|server| server.server_id.as_str()),
2269 Some("local")
2270 );
2271 }
2272
2273 #[test]
2274 fn test_merge_self_hosted_partial_server_override_preserves_existing_fields() {
2275 let mut config = Config::default();
2276 config
2277 .merge_toml_str(
2278 r#"
2279[self_hosted.servers.local]
2280base_url = "http://127.0.0.1:11434"
2281api_style = "responses"
2282"#,
2283 )
2284 .expect("base server");
2285 config
2286 .merge_toml_str(
2287 r#"
2288[self_hosted.servers.local]
2289bearer_token_env = "OLLAMA_TOKEN"
2290"#,
2291 )
2292 .expect("overlay server");
2293
2294 let server = config
2295 .self_hosted
2296 .servers
2297 .get("local")
2298 .expect("merged server");
2299 assert_eq!(server.base_url, "http://127.0.0.1:11434");
2300 assert_eq!(server.api_style, SelfHostedApiStyle::Responses);
2301 assert_eq!(server.bearer_token_env.as_deref(), Some("OLLAMA_TOKEN"));
2302 }
2303
2304 #[test]
2305 fn test_merge_self_hosted_partial_override_preserves_unrelated_inherited_entries() {
2306 let mut config = Config::default();
2307 config
2308 .merge_toml_str(
2309 r#"
2310[self_hosted.servers.local]
2311base_url = "http://127.0.0.1:11434"
2312
2313[self_hosted.servers.backup]
2314base_url = "http://127.0.0.1:11435"
2315
2316[self_hosted.models.gemma-4-e2b]
2317server = "local"
2318remote_model = "gemma4:e2b"
2319display_name = "Gemma 4 E2B"
2320family = "gemma-4"
2321
2322[self_hosted.models.gemma-4-e4b]
2323server = "backup"
2324remote_model = "gemma4:e4b"
2325display_name = "Gemma 4 E4B"
2326family = "gemma-4"
2327"#,
2328 )
2329 .expect("base self-hosted config");
2330 config
2331 .merge_toml_str(
2332 r#"
2333[self_hosted.servers.local]
2334bearer_token_env = "OLLAMA_TOKEN"
2335"#,
2336 )
2337 .expect("overlay self-hosted config");
2338
2339 assert!(config.self_hosted.servers.contains_key("backup"));
2340 assert!(config.self_hosted.models.contains_key("gemma-4-e4b"));
2341 let registry = config
2342 .model_registry()
2343 .expect("registry should remain valid");
2344 assert_eq!(
2345 registry
2346 .entry("gemma-4-e4b")
2347 .and_then(|entry| entry.self_hosted.as_ref())
2348 .map(|server| server.server_id.as_str()),
2349 Some("backup")
2350 );
2351 }
2352
2353 #[test]
2354 fn test_merge_self_hosted_empty_table_clears_inherited_entries() {
2355 let mut config = Config::default();
2356 config
2357 .merge_toml_str(
2358 r#"
2359[self_hosted.servers.local]
2360base_url = "http://127.0.0.1:11434"
2361
2362[self_hosted.models.gemma-4-e2b]
2363server = "local"
2364remote_model = "gemma4:e2b"
2365display_name = "Gemma 4 E2B"
2366family = "gemma-4"
2367"#,
2368 )
2369 .expect("base self-hosted config");
2370
2371 config
2372 .merge_toml_str(
2373 r"
2374[self_hosted.servers]
2375
2376[self_hosted.models]
2377",
2378 )
2379 .expect("clear self-hosted config");
2380
2381 assert!(config.self_hosted.servers.is_empty());
2382 assert!(config.self_hosted.models.is_empty());
2383 }
2384
2385 #[test]
2386 fn test_self_hosted_bearer_token_is_not_serialized() {
2387 let config: Config = toml::from_str(
2388 r#"
2389[self_hosted.servers.local]
2390base_url = "http://127.0.0.1:11434"
2391bearer_token = "secret-token"
2392"#,
2393 )
2394 .expect("config");
2395
2396 let value = serde_json::to_value(&config).expect("serialize config");
2397 let server = &value["self_hosted"]["servers"]["local"];
2398 assert!(
2399 server.get("bearer_token").is_none(),
2400 "literal bearer tokens must be redacted from serialized config"
2401 );
2402 }
2403
2404 #[test]
2413 fn test_merge_toml_tools_omitted_fields_preserve_lower_layer() {
2414 let mut config = Config::default();
2415 config.tools.mob_enabled = true;
2416 config.tools.shell_enabled = true;
2417
2418 config
2419 .merge_toml_str(
2420 r"
2421[tools]
2422shell_enabled = false
2423",
2424 )
2425 .expect("merge should succeed");
2426
2427 assert!(config.tools.mob_enabled);
2428 assert!(!config.tools.shell_enabled);
2429 }
2430
2431 #[test]
2432 fn test_merge_toml_tools_explicit_default_overrides_lower_layer() {
2433 let mut config = Config::default();
2434 config.tools.mob_enabled = true;
2435
2436 config
2437 .merge_toml_str(
2438 r"
2439[tools]
2440mob_enabled = false
2441",
2442 )
2443 .expect("merge should succeed");
2444
2445 assert!(!config.tools.mob_enabled);
2446 }
2447
2448 #[test]
2449 fn test_merge_toml_retry_omitted_fields_preserve_lower_layer() {
2450 let mut config = Config::default();
2451 config.retry.max_retries = 9;
2452
2453 config
2454 .merge_toml_str(
2455 r#"
2456[retry]
2457initial_delay = "750ms"
2458"#,
2459 )
2460 .expect("merge should succeed");
2461
2462 assert_eq!(config.retry.max_retries, 9);
2463 assert_eq!(config.retry.initial_delay, Duration::from_millis(750));
2464 }
2465
2466 #[test]
2467 fn test_compaction_threshold_presence_is_preserved_at_default_value() {
2468 let config: Config = toml::from_str(
2469 r"
2470[compaction]
2471auto_compact_threshold = 100000
2472",
2473 )
2474 .expect("config should parse");
2475
2476 assert_eq!(config.compaction.auto_compact_threshold, 100_000);
2477 assert!(config.compaction.auto_compact_threshold_explicit);
2478 }
2479
2480 #[test]
2481 fn test_default_compaction_threshold_serializes_as_inherited() {
2482 let toml = toml::to_string_pretty(&Config::default()).expect("config should serialize");
2483
2484 assert!(
2485 !toml.contains("auto_compact_threshold"),
2486 "default config should not persist an inherited compaction threshold: {toml}"
2487 );
2488 }
2489
2490 #[test]
2491 fn test_explicit_default_compaction_threshold_serializes() {
2492 let mut config = Config::default();
2493 config.compaction.auto_compact_threshold_explicit = true;
2494
2495 let toml = toml::to_string_pretty(&config).expect("config should serialize");
2496
2497 assert!(
2498 toml.contains("auto_compact_threshold = 100000"),
2499 "explicit default threshold must survive persistence: {toml}"
2500 );
2501 }
2502
2503 #[test]
2504 fn test_validate_rejects_zero_min_turns_between_compactions() {
2505 let config = Config {
2506 compaction: CompactionRuntimeConfig {
2507 min_turns_between_compactions: 0,
2508 ..CompactionRuntimeConfig::default()
2509 },
2510 ..Config::default()
2511 };
2512 let err = config
2513 .validate()
2514 .expect_err("min_turns_between_compactions=0 should be invalid");
2515 assert!(
2516 err.to_string()
2517 .contains("compaction.min_turns_between_compactions")
2518 );
2519 }
2520
2521 #[test]
2527 fn test_budget_config_serialization() {
2528 let budget = BudgetConfig {
2529 max_tokens: Some(100_000),
2530 max_duration: Some(Duration::from_secs(300)),
2531 max_tool_calls: Some(50),
2532 };
2533
2534 let json = serde_json::to_string(&budget).unwrap();
2535 let parsed: BudgetConfig = serde_json::from_str(&json).unwrap();
2536
2537 assert_eq!(parsed.max_tokens, Some(100_000));
2538 assert_eq!(parsed.max_duration, Some(Duration::from_secs(300)));
2539 assert_eq!(parsed.max_tool_calls, Some(50));
2540 }
2541
2542 #[test]
2543 fn test_self_hosted_transport_accepts_openai_compatible_alias() {
2544 let mut config = Config::default();
2545 config
2546 .merge_toml_str(
2547 r#"
2548[self_hosted.servers.ollama]
2549transport = "openai_compatible"
2550base_url = "http://127.0.0.1:11434"
2551api_style = "chat_completions"
2552"#,
2553 )
2554 .expect("alias should parse");
2555
2556 assert_eq!(
2557 config
2558 .self_hosted
2559 .servers
2560 .get("ollama")
2561 .expect("server should exist")
2562 .transport,
2563 SelfHostedTransport::OpenAiCompatible
2564 );
2565 }
2566
2567 #[test]
2568 fn test_self_hosted_server_config_defaults_to_chat_completions() {
2569 assert_eq!(
2570 SelfHostedServerConfig::default().api_style,
2571 SelfHostedApiStyle::ChatCompletions
2572 );
2573 }
2574
2575 #[test]
2576 fn test_retry_config_to_policy() {
2577 let config = RetryConfig::default();
2578 let policy: RetryPolicy = config.into();
2579
2580 assert_eq!(policy.max_retries, 3);
2581 assert_eq!(policy.initial_delay, Duration::from_millis(500));
2582 }
2583
2584 #[tokio::test]
2588 async fn test_regression_load_succeeds_without_config_toml() {
2589 use tempfile::TempDir;
2590
2591 let temp_dir = TempDir::new().unwrap();
2593 let rkat_dir = temp_dir.path().join(".rkat");
2594 std::fs::create_dir(&rkat_dir).unwrap();
2595
2596 assert!(rkat_dir.exists());
2598 assert!(!rkat_dir.join("config.toml").exists());
2599
2600 let result =
2602 Config::load_from_with_env(temp_dir.path(), Some(temp_dir.path()), |_| None).await;
2603
2604 assert!(
2605 result.is_ok(),
2606 "Config::load() should succeed when .rkat/ exists without config.toml: {:?}",
2607 result.err()
2608 );
2609 }
2610
2611 #[test]
2612 fn test_validate_rejects_zero_max_tokens() {
2613 let config = Config {
2614 max_tokens: 0,
2615 ..Config::default()
2616 };
2617 let err = config
2618 .validate()
2619 .expect_err("max_tokens=0 should be invalid");
2620 assert!(
2621 err.to_string()
2622 .contains("max_tokens must be greater than 0")
2623 );
2624 }
2625
2626 #[test]
2627 fn test_validate_rejects_zero_limits_max_sessions() {
2628 let mut config = Config::default();
2629 config.limits.max_sessions = Some(0);
2630 let err = config
2631 .validate()
2632 .expect_err("limits.max_sessions=0 should be invalid");
2633 assert!(err.to_string().contains("limits.max_sessions"));
2634 }
2635
2636 #[test]
2637 fn test_validate_rejects_zero_agent_max_tokens_per_turn() {
2638 let mut config = Config::default();
2639 config.agent.max_tokens_per_turn = 0;
2640 let err = config
2641 .validate()
2642 .expect_err("agent.max_tokens_per_turn=0 should be invalid");
2643 assert!(err.to_string().contains("agent.max_tokens_per_turn"));
2644 }
2645
2646 #[test]
2651 fn test_provider_parse_strict() {
2652 assert_eq!(
2653 Provider::parse_strict("anthropic"),
2654 Some(Provider::Anthropic)
2655 );
2656 assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
2657 assert_eq!(Provider::parse_strict("gemini"), Some(Provider::Gemini));
2658 assert_eq!(Provider::parse_strict("other"), None);
2659 assert_eq!(Provider::parse_strict("claude"), None);
2660 assert_eq!(Provider::parse_strict(""), None);
2661 }
2662
2663 #[test]
2664 fn test_provider_infer_from_model() {
2665 assert_eq!(
2666 Provider::infer_from_model("claude-opus-4-6"),
2667 Some(Provider::Anthropic)
2668 );
2669 assert_eq!(
2670 Provider::infer_from_model("claude-haiku-4-5-20251001"),
2671 Some(Provider::Anthropic)
2672 );
2673 assert_eq!(
2674 Provider::infer_from_model("claude-haiku-4-5"),
2675 Some(Provider::Anthropic)
2676 );
2677 assert_eq!(
2678 Provider::infer_from_model("gpt-5.4"),
2679 Some(Provider::OpenAI)
2680 );
2681 assert_eq!(
2682 Provider::infer_from_model("gpt-5.4-mini"),
2683 Some(Provider::OpenAI)
2684 );
2685 assert_eq!(
2686 Provider::infer_from_model("gemini-3.5-flash"),
2687 Some(Provider::Gemini)
2688 );
2689 assert_eq!(Provider::infer_from_model("gpt-unknown-preview"), None);
2690 assert_eq!(Provider::infer_from_model("claude-unknown-preview"), None);
2691 assert_eq!(Provider::infer_from_model("gemini-unknown-preview"), None);
2692 assert_eq!(Provider::infer_from_model("llama-3"), None);
2693 assert_eq!(Provider::infer_from_model(""), None);
2694 }
2695
2696 #[test]
2699 fn test_comms_auth_mode_default_is_open() {
2700 assert_eq!(CommsAuthMode::default(), CommsAuthMode::Open);
2701 }
2702
2703 #[test]
2704 fn test_comms_auth_mode_serde_roundtrip() {
2705 let json = serde_json::to_string(&CommsAuthMode::Open).unwrap();
2707 assert_eq!(json, r#""none""#);
2708 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2709 assert_eq!(parsed, CommsAuthMode::Open);
2710
2711 let json = serde_json::to_string(&CommsAuthMode::Ed25519).unwrap();
2713 assert_eq!(json, r#""ed25519""#);
2714 let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2715 assert_eq!(parsed, CommsAuthMode::Ed25519);
2716 }
2717
2718 #[test]
2719 fn test_comms_auth_mode_toml_roundtrip() {
2720 let config = CommsRuntimeConfig::default();
2721 let toml_str = toml::to_string(&config).unwrap();
2722 let parsed: CommsRuntimeConfig = toml::from_str(&toml_str).unwrap();
2723 assert_eq!(parsed.auth, CommsAuthMode::Open);
2724 assert!(parsed.require_peer_auth);
2725
2726 let toml_str = r#"
2728mode = "inproc"
2729auth = "ed25519"
2730"#;
2731 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2732 assert_eq!(parsed.auth, CommsAuthMode::Ed25519);
2733 assert!(parsed.require_peer_auth);
2734 }
2735
2736 #[test]
2737 fn test_comms_runtime_config_default_has_open_auth() {
2738 let config = CommsRuntimeConfig::default();
2739 assert_eq!(config.auth, CommsAuthMode::Open);
2740 assert!(config.require_peer_auth);
2741 }
2742
2743 #[test]
2746 fn test_plain_event_source_serde_roundtrip() {
2747 let cases = [
2748 (PlainEventSource::Tcp, r#""tcp""#),
2749 (PlainEventSource::Uds, r#""uds""#),
2750 (PlainEventSource::Stdin, r#""stdin""#),
2751 (PlainEventSource::Webhook, r#""webhook""#),
2752 (PlainEventSource::Rpc, r#""rpc""#),
2753 ];
2754 for (variant, expected_json) in cases {
2755 let json = serde_json::to_string(&variant).unwrap();
2756 assert_eq!(json, expected_json, "serialize {variant:?}");
2757 let parsed: PlainEventSource = serde_json::from_str(&json).unwrap();
2758 assert_eq!(parsed, variant, "deserialize {variant:?}");
2759 }
2760 }
2761
2762 #[test]
2763 fn test_plain_event_source_display() {
2764 assert_eq!(PlainEventSource::Tcp.to_string(), "tcp");
2765 assert_eq!(PlainEventSource::Uds.to_string(), "uds");
2766 assert_eq!(PlainEventSource::Stdin.to_string(), "stdin");
2767 assert_eq!(PlainEventSource::Webhook.to_string(), "webhook");
2768 assert_eq!(PlainEventSource::Rpc.to_string(), "rpc");
2769 }
2770
2771 #[test]
2774 fn test_comms_config_event_address_toml_roundtrip() {
2775 let toml_str = r#"
2776mode = "tcp"
2777address = "127.0.0.1:4200"
2778auth = "none"
2779require_peer_auth = false
2780event_address = "127.0.0.1:4201"
2781"#;
2782 let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2783 assert_eq!(parsed.event_address.as_deref(), Some("127.0.0.1:4201"));
2784 assert_eq!(parsed.auth, CommsAuthMode::Open);
2785 assert!(!parsed.require_peer_auth);
2786 }
2787
2788 #[test]
2789 fn test_comms_config_event_address_defaults_none() {
2790 let config = CommsRuntimeConfig::default();
2791 assert!(config.event_address.is_none());
2792 }
2793
2794 #[test]
2797 fn call_timeout_override_default_is_inherit() {
2798 assert_eq!(CallTimeoutOverride::default(), CallTimeoutOverride::Inherit);
2799 assert!(CallTimeoutOverride::default().is_inherit());
2800 }
2801
2802 #[test]
2803 fn call_timeout_override_disabled_is_not_inherit() {
2804 assert!(!CallTimeoutOverride::Disabled.is_inherit());
2805 }
2806
2807 #[test]
2808 fn call_timeout_override_value_is_not_inherit() {
2809 assert!(!CallTimeoutOverride::Value(Duration::from_secs(45)).is_inherit());
2810 }
2811
2812 #[test]
2813 fn call_timeout_override_toml_deserialize_disabled() {
2814 let toml_str = r#"call_timeout = "disabled""#;
2815 #[derive(Deserialize)]
2816 struct Wrapper {
2817 call_timeout: CallTimeoutOverride,
2818 }
2819 let w: Wrapper = toml::from_str(toml_str).unwrap();
2820 assert_eq!(w.call_timeout, CallTimeoutOverride::Disabled);
2821 }
2822
2823 #[test]
2824 fn call_timeout_override_toml_deserialize_duration() {
2825 let toml_str = r#"call_timeout = "45s""#;
2826 #[derive(Deserialize)]
2827 struct Wrapper {
2828 call_timeout: CallTimeoutOverride,
2829 }
2830 let w: Wrapper = toml::from_str(toml_str).unwrap();
2831 assert_eq!(
2832 w.call_timeout,
2833 CallTimeoutOverride::Value(Duration::from_secs(45))
2834 );
2835 }
2836
2837 #[test]
2838 fn call_timeout_override_toml_deserialize_complex_duration() {
2839 let toml_str = r#"call_timeout = "2m 30s""#;
2840 #[derive(Deserialize)]
2841 struct Wrapper {
2842 call_timeout: CallTimeoutOverride,
2843 }
2844 let w: Wrapper = toml::from_str(toml_str).unwrap();
2845 assert_eq!(
2846 w.call_timeout,
2847 CallTimeoutOverride::Value(Duration::from_secs(150))
2848 );
2849 }
2850
2851 #[test]
2852 fn retry_config_default_has_inherit_call_timeout() {
2853 let config = RetryConfig::default();
2854 assert_eq!(config.call_timeout_override, CallTimeoutOverride::Inherit);
2855 }
2856
2857 #[test]
2858 fn retry_config_from_toml_with_call_timeout_value() {
2859 let toml_str = r#"
2860[retry]
2861max_retries = 5
2862call_timeout = "60s"
2863"#;
2864 let config: Config = toml::from_str(toml_str).unwrap();
2865 assert_eq!(config.retry.max_retries, 5);
2866 assert_eq!(
2867 config.retry.call_timeout_override,
2868 CallTimeoutOverride::Value(Duration::from_secs(60))
2869 );
2870 }
2871
2872 #[test]
2873 fn retry_config_from_toml_with_call_timeout_disabled() {
2874 let toml_str = r#"
2875[retry]
2876call_timeout = "disabled"
2877"#;
2878 let config: Config = toml::from_str(toml_str).unwrap();
2879 assert_eq!(
2880 config.retry.call_timeout_override,
2881 CallTimeoutOverride::Disabled
2882 );
2883 }
2884
2885 #[test]
2886 fn retry_config_from_toml_omitted_is_inherit() {
2887 let toml_str = r"
2888[retry]
2889max_retries = 2
2890";
2891 let config: Config = toml::from_str(toml_str).unwrap();
2892 assert_eq!(
2893 config.retry.call_timeout_override,
2894 CallTimeoutOverride::Inherit
2895 );
2896 }
2897
2898 #[test]
2899 fn retry_policy_from_config_with_value_override() {
2900 let config = RetryConfig {
2901 call_timeout_override: CallTimeoutOverride::Value(Duration::from_secs(90)),
2902 ..RetryConfig::default()
2903 };
2904 let policy: crate::retry::RetryPolicy = config.into();
2905 assert_eq!(policy.call_timeout, Some(Duration::from_secs(90)));
2906 }
2907
2908 #[test]
2909 fn retry_policy_from_config_with_disabled_override() {
2910 let config = RetryConfig {
2911 call_timeout_override: CallTimeoutOverride::Disabled,
2912 ..RetryConfig::default()
2913 };
2914 let policy: crate::retry::RetryPolicy = config.into();
2915 assert_eq!(policy.call_timeout, None);
2917 }
2918
2919 #[test]
2920 fn retry_policy_from_config_with_inherit_override() {
2921 let config = RetryConfig {
2922 call_timeout_override: CallTimeoutOverride::Inherit,
2923 ..RetryConfig::default()
2924 };
2925 let policy: crate::retry::RetryPolicy = config.into();
2926 assert_eq!(policy.call_timeout, None);
2927 }
2928
2929 #[test]
2930 fn config_merge_preserves_call_timeout_override() {
2931 let toml_base = r"
2932[retry]
2933max_retries = 2
2934";
2935 let toml_overlay = r#"
2936[retry]
2937call_timeout = "30s"
2938"#;
2939 let mut config: Config = toml::from_str(toml_base).unwrap();
2940 let overlay: Config = toml::from_str(toml_overlay).unwrap();
2941 let overlay_parsed: toml::Value = toml::from_str(toml_overlay).unwrap();
2942 config.merge_retry_from_toml_presence(&overlay_parsed, &overlay.retry);
2943 assert_eq!(config.retry.max_retries, 2); assert_eq!(
2945 config.retry.call_timeout_override,
2946 CallTimeoutOverride::Value(Duration::from_secs(30))
2947 );
2948 }
2949
2950 #[test]
2953 fn test_provider_tools_defaults_all_enabled() {
2954 let config = Config::default();
2955 assert!(config.provider_tools.anthropic.web_search);
2956 assert!(config.provider_tools.openai.web_search);
2957 assert!(config.provider_tools.gemini.google_search);
2958 }
2959
2960 #[test]
2961 fn test_provider_tools_roundtrip_toml() {
2962 let config = Config::default();
2963 let toml_str = toml::to_string(&config.provider_tools).unwrap();
2964 let parsed: ProviderToolsConfig = toml::from_str(&toml_str).unwrap();
2965 assert_eq!(parsed, config.provider_tools);
2966 }
2967
2968 #[test]
2969 fn test_provider_tools_merge_preserves_when_absent() {
2970 let mut config = Config::default();
2971 config
2972 .merge_toml_str(
2973 r#"[agent]
2974model = "custom-model"
2975"#,
2976 )
2977 .unwrap();
2978 assert!(config.provider_tools.anthropic.web_search);
2980 assert!(config.provider_tools.openai.web_search);
2981 assert!(config.provider_tools.gemini.google_search);
2982 }
2983
2984 #[test]
2985 fn test_provider_tools_merge_overrides_single_provider() {
2986 let mut config = Config::default();
2987 config
2988 .merge_toml_str("[provider_tools.anthropic]\nweb_search = false\n")
2989 .unwrap();
2990 assert!(!config.provider_tools.anthropic.web_search);
2992 assert!(config.provider_tools.openai.web_search);
2994 assert!(config.provider_tools.gemini.google_search);
2995 }
2996
2997 #[test]
3000 fn test_provider_tool_defaults_not_serialized() {
3001 let agent_config = AgentConfig {
3002 provider_tool_defaults: Some(
3003 serde_json::json!({"web_search": {"type": "web_search_20250305"}}),
3004 ),
3005 ..Default::default()
3006 };
3007 let json = serde_json::to_value(&agent_config).unwrap();
3008 assert!(
3009 json.get("provider_tool_defaults").is_none(),
3010 "provider_tool_defaults must not be serialized: {json}"
3011 );
3012 }
3013}