Skip to main content

autumn_plugin_agent/
config.rs

1//! Layered configuration for the agent plugin.
2//!
3//! The plugin reads its settings from the `[agent]` section of `autumn.toml`
4//! (declared strict-config-safe through
5//! [`AppBuilder::config_section`](autumn_web::app::AppBuilder::config_section)),
6//! then applies `AGENT_*` environment variables on top. Precedence, weakest
7//! to strongest: compiled defaults < `autumn.toml` < environment <
8//! [`AgentPlugin::configure`](crate::plugin::AgentPlugin::configure).
9//!
10//! The API key is the deliberate exception: it comes **only** from the
11//! `AGENT_API_KEY` environment variable. A key sitting in `autumn.toml`
12//! fails config loading outright instead of being silently accepted.
13//!
14//! ```toml
15//! [agent]
16//! provider = "openai-compatible" # or "anthropic"
17//! model = "gpt-4o-mini"
18//! base_url = "http://localhost:11434/v1" # Ollama, vLLM, ...
19//! max_steps = 10
20//! max_tokens = 32000
21//! request_timeout_secs = 60
22//! ```
23
24use std::str::FromStr;
25
26use serde::Deserialize;
27
28use crate::error::{AgentError, ErrorKind};
29
30/// Selects the wire protocol the plugin speaks to the provider.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum ProviderKind {
33    /// OpenAI chat-completions protocol (`POST /chat/completions`).
34    ///
35    /// Covers OpenAI itself plus OpenAI-compatible endpoints such as Ollama,
36    /// vLLM, and LM Studio. Pick this unless the provider only speaks
37    /// Anthropic's API.
38    #[default]
39    OpenAiCompatible,
40    /// Anthropic Messages API (`POST /v1/messages`).
41    Anthropic,
42}
43
44impl ProviderKind {
45    /// The config-file spelling of this provider.
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::OpenAiCompatible => "openai-compatible",
50            Self::Anthropic => "anthropic",
51        }
52    }
53
54    /// Default model used when the config leaves `model` unset.
55    ///
56    /// Pin a model in `autumn.toml` for production use; defaults drift as
57    /// providers rename models.
58    #[must_use]
59    pub const fn default_model(self) -> &'static str {
60        match self {
61            Self::OpenAiCompatible => "gpt-4o-mini",
62            Self::Anthropic => "claude-sonnet-4-5",
63        }
64    }
65
66    /// Default base URL used when the config leaves `base_url` unset.
67    #[must_use]
68    pub const fn default_base_url(self) -> &'static str {
69        match self {
70            Self::OpenAiCompatible => "https://api.openai.com/v1",
71            Self::Anthropic => "https://api.anthropic.com",
72        }
73    }
74}
75
76impl FromStr for ProviderKind {
77    type Err = AgentError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.trim().to_ascii_lowercase().as_str() {
81            "openai-compatible" | "openai_compatible" | "openai" => Ok(Self::OpenAiCompatible),
82            "anthropic" => Ok(Self::Anthropic),
83            other => Err(AgentError::new(
84                ErrorKind::Config,
85                format!(
86                    r#"unknown agent provider "{other}": expected "openai-compatible" or "anthropic""#
87                ),
88            )),
89        }
90    }
91}
92
93impl std::fmt::Display for ProviderKind {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99impl<'de> Deserialize<'de> for ProviderKind {
100    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101    where
102        D: serde::Deserializer<'de>,
103    {
104        let raw = String::deserialize(deserializer)?;
105        Self::from_str(&raw).map_err(serde::de::Error::custom)
106    }
107}
108
109/// Configuration for the agent plugin.
110///
111/// Build it with [`AgentConfig::load`] (file + env), tweak it in tests with
112/// [`AgentConfig::from_toml_str`], or construct it by hand and adjust fields.
113#[derive(Debug, Clone)]
114pub struct AgentConfig {
115    /// Which provider protocol to speak.
116    pub provider: ProviderKind,
117    /// Model name, or `None` for the provider default.
118    pub model: Option<String>,
119    /// Provider base URL, or `None` for the provider default.
120    pub base_url: Option<String>,
121    /// Per-request HTTP timeout in seconds.
122    pub request_timeout_secs: u64,
123    /// Maximum agent-loop iterations before the run stops.
124    pub max_steps: u32,
125    /// Token budget for one agent run (estimated + provider-reported).
126    pub max_tokens: u32,
127    /// System prompt prepended to every agent run, if set.
128    pub system_prompt: Option<String>,
129    /// Mark Anthropic prompt-cache breakpoints. On by default; OpenAI caches
130    /// on its own and ignores this.
131    pub prompt_caching: bool,
132    /// Wall-clock limit for one agent run in seconds, or `None` for no limit.
133    pub max_run_secs: Option<u64>,
134}
135
136impl Default for AgentConfig {
137    fn default() -> Self {
138        Self {
139            provider: ProviderKind::default(),
140            model: None,
141            base_url: None,
142            request_timeout_secs: 60,
143            max_steps: 10,
144            max_tokens: 32_000,
145            system_prompt: None,
146            prompt_caching: true,
147            max_run_secs: None,
148        }
149    }
150}
151
152/// Raw `[agent]` table: every field optional.
153///
154/// The API key is deliberately absent: it lives in `AGENT_API_KEY`, never in
155/// the file. [`AgentConfig::from_toml_str`] rejects `api_key`/`api-key`
156/// before deserializing, so no field is needed here.
157#[derive(Debug, Default, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct RawAgentConfig {
160    provider: Option<ProviderKind>,
161    model: Option<String>,
162    base_url: Option<String>,
163    request_timeout_secs: Option<u64>,
164    max_steps: Option<u32>,
165    max_tokens: Option<u32>,
166    system_prompt: Option<String>,
167    prompt_caching: Option<bool>,
168    max_run_secs: Option<u64>,
169}
170
171impl AgentConfig {
172    /// Load configuration from `autumn.toml` plus the environment.
173    ///
174    /// Reads `./autumn.toml` (or the file named by `AGENT_CONFIG_FILE`),
175    /// applies the `[agent]` table, then applies `AGENT_*` overrides.
176    /// A missing file is fine: defaults plus environment still work.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`AgentError`] with [`ErrorKind::Config`]
181    /// when the file cannot be read or parsed, an `AGENT_*` variable has an
182    /// invalid value, or the merged configuration fails validation.
183    pub fn load() -> Result<Self, AgentError> {
184        let path = std::env::var("AGENT_CONFIG_FILE").unwrap_or_else(|_| "autumn.toml".to_owned());
185        let mut config = match std::fs::read_to_string(&path) {
186            Ok(text) => Self::from_toml_str(&text)?,
187            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),
188            Err(err) => {
189                return Err(AgentError::with_source(
190                    ErrorKind::Config,
191                    format!("cannot read agent config file {path}"),
192                    err,
193                ));
194            }
195        };
196        config.apply_env()?;
197        config.validate()?;
198        Ok(config)
199    }
200
201    /// Parse the `[agent]` table out of a TOML document.
202    ///
203    /// Pure and side-effect free: this is the seam unit tests exercise.
204    /// A missing `[agent]` table yields defaults.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`AgentError`] with [`ErrorKind::Config`]
209    /// when the document is not valid TOML, `[agent]` is not a table, the
210    /// table sets `api_key`/`api-key` (fail-closed: the key must come from
211    /// `AGENT_API_KEY`), or a field fails validation.
212    pub fn from_toml_str(document: &str) -> Result<Self, AgentError> {
213        let table: toml::Table = document.parse().map_err(|err| {
214            AgentError::with_source(ErrorKind::Config, "invalid TOML in agent config", err)
215        })?;
216        let Some(section) = table.get("agent") else {
217            return Ok(Self::default());
218        };
219        let section_table = section
220            .as_table()
221            .ok_or_else(|| AgentError::new(ErrorKind::Config, "[agent] must be a TOML table"))?;
222        // Fail closed on secrets before touching any other field.
223        if section_table.contains_key("api_key") || section_table.contains_key("api-key") {
224            return Err(AgentError::new(
225                ErrorKind::Config,
226                "refusing to load: api_key must come from the AGENT_API_KEY environment variable, never from the config file",
227            ));
228        }
229        let raw: RawAgentConfig = section_table.clone().try_into().map_err(|err| {
230            let detail = err.to_string();
231            AgentError::with_source(
232                ErrorKind::Config,
233                format!("invalid [agent] table: {detail}"),
234                err,
235            )
236        })?;
237        let mut config = Self::default();
238        if let Some(provider) = raw.provider {
239            config.provider = provider;
240        }
241        if let Some(model) = raw.model {
242            config.model = Some(model);
243        }
244        if let Some(base_url) = raw.base_url {
245            config.base_url = Some(base_url);
246        }
247        if let Some(secs) = raw.request_timeout_secs {
248            config.request_timeout_secs = secs;
249        }
250        if let Some(steps) = raw.max_steps {
251            config.max_steps = steps;
252        }
253        if let Some(tokens) = raw.max_tokens {
254            config.max_tokens = tokens;
255        }
256        if let Some(prompt) = raw.system_prompt {
257            config.system_prompt = Some(prompt);
258        }
259        if let Some(caching) = raw.prompt_caching {
260            config.prompt_caching = caching;
261        }
262        if let Some(secs) = raw.max_run_secs {
263            config.max_run_secs = Some(secs);
264        }
265        config.validate()?;
266        Ok(config)
267    }
268
269    /// Apply `AGENT_*` environment variables over the current values.
270    ///
271    /// Recognised: `AGENT_PROVIDER`, `AGENT_MODEL`, `AGENT_BASE_URL`,
272    /// `AGENT_REQUEST_TIMEOUT_SECS`, `AGENT_MAX_STEPS`, `AGENT_MAX_TOKENS`,
273    /// `AGENT_SYSTEM_PROMPT`, `AGENT_PROMPT_CACHING` (`true`/`false`),
274    /// `AGENT_MAX_RUN_SECS`. Unset variables leave the value alone.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`AgentError`] with [`ErrorKind::Config`]
279    /// when a set variable has an invalid value (unknown provider, malformed
280    /// number, or a number that does not fit its field).
281    pub fn apply_env(&mut self) -> Result<(), AgentError> {
282        self.apply_env_with(|name| std::env::var(name).ok())
283    }
284
285    /// Apply environment variables resolved through `get`.
286    ///
287    /// Test seam for [`AgentConfig::apply_env`]: pass a map lookup instead of
288    /// touching the process environment, which is `unsafe` to mutate on
289    /// Rust 2024 and forbidden by this crate's `unsafe_code = "forbid"`.
290    pub(crate) fn apply_env_with(
291        &mut self,
292        get: impl Fn(&str) -> Option<String>,
293    ) -> Result<(), AgentError> {
294        if let Some(raw) = get("AGENT_PROVIDER") {
295            self.provider = ProviderKind::from_str(&raw)?;
296        }
297        if let Some(model) = get("AGENT_MODEL") {
298            self.model = Some(model);
299        }
300        if let Some(base_url) = get("AGENT_BASE_URL") {
301            self.base_url = Some(base_url);
302        }
303        if let Some(raw) = get("AGENT_REQUEST_TIMEOUT_SECS") {
304            self.request_timeout_secs = parse_env_u64("AGENT_REQUEST_TIMEOUT_SECS", &raw)?;
305        }
306        if let Some(raw) = get("AGENT_MAX_STEPS") {
307            let steps = parse_env_u64("AGENT_MAX_STEPS", &raw)?;
308            self.max_steps = u32::try_from(steps).map_err(|_| {
309                AgentError::new(ErrorKind::Config, "AGENT_MAX_STEPS does not fit in a u32")
310            })?;
311        }
312        if let Some(raw) = get("AGENT_MAX_TOKENS") {
313            let tokens = parse_env_u64("AGENT_MAX_TOKENS", &raw)?;
314            self.max_tokens = u32::try_from(tokens).map_err(|_| {
315                AgentError::new(ErrorKind::Config, "AGENT_MAX_TOKENS does not fit in a u32")
316            })?;
317        }
318        if let Some(prompt) = get("AGENT_SYSTEM_PROMPT") {
319            self.system_prompt = Some(prompt);
320        }
321        if let Some(raw) = get("AGENT_PROMPT_CACHING") {
322            self.prompt_caching = parse_env_bool("AGENT_PROMPT_CACHING", &raw)?;
323        }
324        if let Some(raw) = get("AGENT_MAX_RUN_SECS") {
325            self.max_run_secs = Some(parse_env_u64("AGENT_MAX_RUN_SECS", &raw)?);
326        }
327        Ok(())
328    }
329
330    /// Read the provider API key from the environment.
331    ///
332    /// The key comes only from `AGENT_API_KEY`. Never log the return value.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`AgentError`] with [`ErrorKind::Config`]
337    /// when `AGENT_API_KEY` is not set.
338    pub fn api_key(&self) -> Result<String, AgentError> {
339        Self::api_key_with(|name| std::env::var(name).ok())
340    }
341
342    /// Read the API key resolved through `get`.
343    ///
344    /// Test seam for [`AgentConfig::api_key`]; see
345    /// [`AgentConfig::apply_env_with`].
346    ///
347    /// # Errors
348    ///
349    /// Returns [`AgentError`] with [`ErrorKind::Config`]
350    /// when `get("AGENT_API_KEY")` returns [`None`].
351    pub(crate) fn api_key_with(get: impl Fn(&str) -> Option<String>) -> Result<String, AgentError> {
352        get("AGENT_API_KEY").ok_or_else(|| {
353            AgentError::new(
354                ErrorKind::Config,
355                "AGENT_API_KEY is not set: export it before starting the app",
356            )
357        })
358    }
359
360    /// Check every field for sanity. Called by [`AgentConfig::load`] and
361    /// [`AgentConfig::from_toml_str`]; call it again after hand-editing.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`AgentError`] with [`ErrorKind::Config`]
366    /// when any field is out of its accepted range or malformed.
367    pub fn validate(&self) -> Result<(), AgentError> {
368        if self.request_timeout_secs == 0 || self.request_timeout_secs > 3_600 {
369            return Err(AgentError::new(
370                ErrorKind::Config,
371                "request_timeout_secs must be between 1 and 3600",
372            ));
373        }
374        if self.max_steps == 0 || self.max_steps > 1_000 {
375            return Err(AgentError::new(
376                ErrorKind::Config,
377                "max_steps must be between 1 and 1000",
378            ));
379        }
380        if self.max_tokens < 1_000 || self.max_tokens > 1_000_000 {
381            return Err(AgentError::new(
382                ErrorKind::Config,
383                "max_tokens must be between 1000 and 1000000",
384            ));
385        }
386        if let Some(secs) = self.max_run_secs
387            && (secs == 0 || secs > 86_400)
388        {
389            return Err(AgentError::new(
390                ErrorKind::Config,
391                "max_run_secs must be between 1 and 86400",
392            ));
393        }
394        if let Some(model) = self.model.as_deref()
395            && model.trim().is_empty()
396        {
397            return Err(AgentError::new(
398                ErrorKind::Config,
399                "model must not be blank",
400            ));
401        }
402        if let Some(base_url) = self.base_url.as_deref() {
403            let url = reqwest::Url::parse(base_url).map_err(|err| {
404                AgentError::with_source(ErrorKind::Config, "base_url is not a valid URL", err)
405            })?;
406            if url.scheme() != "http" && url.scheme() != "https" {
407                return Err(AgentError::new(
408                    ErrorKind::Config,
409                    "base_url must use the http or https scheme",
410                ));
411            }
412        }
413        Ok(())
414    }
415
416    /// Effective model name: the configured one, or the provider default.
417    #[must_use]
418    pub fn resolved_model(&self) -> &str {
419        self.model
420            .as_deref()
421            .filter(|model| !model.trim().is_empty())
422            .unwrap_or_else(|| self.provider.default_model())
423    }
424
425    /// Effective base URL: the configured one, or the provider default.
426    #[must_use]
427    pub fn resolved_base_url(&self) -> &str {
428        self.base_url
429            .as_deref()
430            .unwrap_or_else(|| self.provider.default_base_url())
431    }
432}
433
434/// Parse a `AGENT_*` integer variable, naming the variable on failure.
435fn parse_env_u64(name: &str, raw: &str) -> Result<u64, AgentError> {
436    raw.trim().parse::<u64>().map_err(|_| {
437        AgentError::new(
438            ErrorKind::Config,
439            format!("{name} must be a non-negative integer, got {raw:?}"),
440        )
441    })
442}
443
444/// Parse a `AGENT_*` boolean variable, naming the variable on failure.
445fn parse_env_bool(name: &str, raw: &str) -> Result<bool, AgentError> {
446    match raw.trim().to_ascii_lowercase().as_str() {
447        "1" | "true" | "yes" | "on" => Ok(true),
448        "0" | "false" | "no" | "off" => Ok(false),
449        _ => Err(AgentError::new(
450            ErrorKind::Config,
451            format!("{name} must be true or false, got {raw:?}"),
452        )),
453    }
454}
455
456#[cfg(test)]
457mod tests;