Skip to main content

agentic_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use crate::error::Error;
6use crate::tool::McpServerEntry;
7
8pub const AGENTIC_API_HOME_ENV: &str = "AGENTIC_API_HOME";
9pub const CONFIG_FILE_NAME: &str = "config.toml";
10pub const DATABASE_FILE_NAME: &str = "agentic_api.db";
11
12pub const DEFAULT_POSTGRES_MAX_CONNECTIONS: u32 = 10;
13pub const DEFAULT_POSTGRES_ACQUIRE_TIMEOUT_SECONDS: u64 = 30;
14pub const DEFAULT_POSTGRES_IDLE_TIMEOUT_SECONDS: u64 = 600;
15pub const DEFAULT_POSTGRES_LOCK_TIMEOUT_SECONDS: u64 = 5;
16pub const DEFAULT_POSTGRES_MAX_LIFETIME_SECONDS: u64 = 1_800;
17pub const DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS: u64 = 300;
18pub const DEFAULT_POSTGRES_STATEMENT_TIMEOUT_SECONDS: u64 = 30;
19pub const DEFAULT_SQLITE_MAX_CONNECTIONS: u32 = 4;
20pub const DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES: u64 = 6_144_000;
21pub const DEFAULT_SQLITE_MMAP_SIZE_BYTES: u64 = 268_435_456;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct PostgresConfig {
25    pub max_connections: u32,
26    pub acquire_timeout: Duration,
27    pub lock_timeout: Duration,
28    pub migration_timeout: Duration,
29    pub statement_timeout: Duration,
30    pub idle_timeout: Option<Duration>,
31    pub max_lifetime: Option<Duration>,
32}
33
34impl Default for PostgresConfig {
35    fn default() -> Self {
36        Self {
37            max_connections: DEFAULT_POSTGRES_MAX_CONNECTIONS,
38            acquire_timeout: Duration::from_secs(DEFAULT_POSTGRES_ACQUIRE_TIMEOUT_SECONDS),
39            lock_timeout: Duration::from_secs(DEFAULT_POSTGRES_LOCK_TIMEOUT_SECONDS),
40            migration_timeout: Duration::from_secs(DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS),
41            statement_timeout: Duration::from_secs(DEFAULT_POSTGRES_STATEMENT_TIMEOUT_SECONDS),
42            idle_timeout: Some(Duration::from_secs(DEFAULT_POSTGRES_IDLE_TIMEOUT_SECONDS)),
43            max_lifetime: Some(Duration::from_secs(DEFAULT_POSTGRES_MAX_LIFETIME_SECONDS)),
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum SqliteTempStore {
50    Default,
51    File,
52    #[default]
53    Memory,
54}
55
56impl SqliteTempStore {
57    #[must_use]
58    pub fn as_pragma_value(self) -> &'static str {
59        match self {
60            Self::Default => "DEFAULT",
61            Self::File => "FILE",
62            Self::Memory => "MEMORY",
63        }
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct SqliteConfig {
69    pub max_connections: u32,
70    pub journal_size_limit_bytes: u64,
71    pub temp_store: SqliteTempStore,
72    pub mmap_size_bytes: u64,
73}
74
75impl Default for SqliteConfig {
76    fn default() -> Self {
77        Self {
78            max_connections: DEFAULT_SQLITE_MAX_CONNECTIONS,
79            journal_size_limit_bytes: DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES,
80            temp_store: SqliteTempStore::default(),
81            mmap_size_bytes: DEFAULT_SQLITE_MMAP_SIZE_BYTES,
82        }
83    }
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct WebSearchProviderConfig {
88    pub api_key: Option<String>,
89    pub base_url: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct ToolRuntimeConfig {
94    pub web_search: WebSearchProviderConfig,
95    pub mcp_servers: HashMap<String, McpServerEntry>,
96    pub mcp_allowed_hosts: Vec<String>,
97    pub messages_gateway_tool_aliases: Option<String>,
98}
99
100#[derive(Debug, Clone)]
101pub struct Config {
102    pub llm_api_base: String,
103    pub openai_api_key: Option<String>,
104    pub llm_ready_timeout_s: f64,
105    pub llm_ready_interval_s: f64,
106    pub skip_llm_ready_check: bool,
107    /// Database URL for conversation and response storage.
108    /// `None` uses the local database in the Agentic API home directory.
109    pub db_url: Option<String>,
110    pub postgres: PostgresConfig,
111    pub sqlite: SqliteConfig,
112    pub tools: ToolRuntimeConfig,
113}
114
115/// Resolves the directory used for user configuration and local state.
116///
117/// `AGENTIC_API_HOME` takes precedence over the default `~/.agentic-api`.
118/// The returned path is absolute, but it is not created by this function.
119///
120/// # Errors
121///
122/// Returns a configuration error when the home directory cannot be found or
123/// `AGENTIC_API_HOME` is not an absolute path.
124pub fn agentic_api_home() -> Result<PathBuf, Error> {
125    let configured = std::env::var_os(AGENTIC_API_HOME_ENV).filter(|value| !value.is_empty());
126    resolve_agentic_api_home(configured.map(PathBuf::from), dirs::home_dir())
127}
128
129fn resolve_agentic_api_home(configured: Option<PathBuf>, user_home: Option<PathBuf>) -> Result<PathBuf, Error> {
130    if let Some(path) = configured {
131        if !path.is_absolute() {
132            return Err(Error::Config(format!(
133                "{AGENTIC_API_HOME_ENV} must be an absolute path: {}",
134                path.display()
135            )));
136        }
137        return Ok(path);
138    }
139
140    let user_home = user_home.ok_or_else(|| Error::Config("could not determine the user home directory".to_owned()))?;
141    if !user_home.is_absolute() {
142        return Err(Error::Config(format!(
143            "user home directory must be an absolute path: {}",
144            user_home.display()
145        )));
146    }
147    Ok(user_home.join(".agentic-api"))
148}
149
150/// Resolves and creates the Agentic API home directory.
151///
152/// # Errors
153///
154/// Returns an error when the path cannot be resolved or created, or when an
155/// existing path is not a directory.
156pub fn ensure_agentic_api_home() -> Result<PathBuf, Error> {
157    let path = agentic_api_home()?;
158    std::fs::create_dir_all(&path).map_err(|error| {
159        Error::Config(format!(
160            "failed to create Agentic API home directory {}: {error}",
161            path.display()
162        ))
163    })?;
164    if !path.is_dir() {
165        return Err(Error::Config(format!(
166            "Agentic API home path is not a directory: {}",
167            path.display()
168        )));
169    }
170    Ok(path)
171}
172
173/// Returns the default `SQLite` URL inside the Agentic API home directory.
174///
175/// # Errors
176///
177/// Returns an error when the home directory cannot be resolved or created.
178pub fn default_database_url() -> Result<String, Error> {
179    default_database_url_in(&ensure_agentic_api_home()?)
180}
181
182fn default_database_url_in(home: &Path) -> Result<String, Error> {
183    const SQLITE_PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
184        .add(b' ')
185        .add(b'"')
186        .add(b'#')
187        .add(b'<')
188        .add(b'>')
189        .add(b'?')
190        .add(b'%')
191        .add(b'`')
192        .add(b'{')
193        .add(b'}');
194
195    let path = home.join(DATABASE_FILE_NAME);
196    let path = path
197        .to_str()
198        .ok_or_else(|| Error::Config(format!("default database path is not valid UTF-8: {}", path.display())))?;
199    #[cfg(windows)]
200    let path = path.replace('\\', "/");
201    let encoded = percent_encoding::utf8_percent_encode(path, SQLITE_PATH_ENCODE_SET);
202    Ok(format!("sqlite://{encoded}"))
203}
204
205#[must_use]
206pub fn normalize_base_url(url: &str) -> String {
207    let mut s = url.trim_end_matches('/').to_owned();
208    if s.ends_with("/v1") {
209        s.truncate(s.len() - 3);
210        s = s.trim_end_matches('/').to_owned();
211    }
212    s
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::path::PathBuf;
219
220    #[test]
221    fn strip_trailing_v1() {
222        assert_eq!(normalize_base_url("http://host:8000/v1"), "http://host:8000");
223        assert_eq!(normalize_base_url("http://host:8000/v1/"), "http://host:8000");
224    }
225
226    #[test]
227    fn no_v1_unchanged() {
228        assert_eq!(normalize_base_url("http://host:8000"), "http://host:8000");
229        assert_eq!(normalize_base_url("http://host:8000/"), "http://host:8000");
230    }
231
232    #[test]
233    fn home_override_takes_precedence() {
234        let configured = if cfg!(windows) {
235            PathBuf::from(r"C:\agentic-home")
236        } else {
237            PathBuf::from("/tmp/agentic-home")
238        };
239        let resolved = resolve_agentic_api_home(Some(configured.clone()), Some(PathBuf::from("/ignored")))
240            .expect("absolute configured home");
241        assert_eq!(resolved, configured);
242    }
243
244    #[test]
245    fn default_home_is_hidden_directory() {
246        let user_home = if cfg!(windows) {
247            PathBuf::from(r"C:\Users\agentic")
248        } else {
249            PathBuf::from("/home/agentic")
250        };
251        let resolved = resolve_agentic_api_home(None, Some(user_home.clone())).expect("user home");
252        assert_eq!(resolved, user_home.join(".agentic-api"));
253    }
254
255    #[test]
256    fn relative_home_override_is_rejected() {
257        let error = resolve_agentic_api_home(Some(PathBuf::from("relative")), Some(PathBuf::from("/home/agentic")))
258            .expect_err("relative override must fail");
259        assert!(error.to_string().contains("must be an absolute path"));
260    }
261
262    #[test]
263    fn database_url_uses_home_directory() {
264        let home = if cfg!(windows) {
265            PathBuf::from(r"C:\Users\agentic\.agentic-api")
266        } else {
267            PathBuf::from("/home/agentic/.agentic-api")
268        };
269        let url = default_database_url_in(&home).expect("database URL");
270        assert!(url.starts_with("sqlite://"));
271        assert!(url.ends_with("/.agentic-api/agentic_api.db"));
272    }
273
274    #[test]
275    fn database_url_encodes_url_delimiters_in_home_path() {
276        let home = if cfg!(windows) {
277            PathBuf::from(r"C:\Users\agentic api\state?#%")
278        } else {
279            PathBuf::from("/home/agentic api/state?#%")
280        };
281        let url = default_database_url_in(&home).expect("database URL");
282        assert!(url.contains("agentic%20api"));
283        assert!(url.contains("state%3F%23%25"));
284    }
285}