Skip to main content

a_agent/
config.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ProviderKind {
13    Anthropic,
14    Responses,
15    Chatcompletion,
16}
17
18impl ProviderKind {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Anthropic => "anthropic",
22            Self::Responses => "responses",
23            Self::Chatcompletion => "chatcompletion",
24        }
25    }
26
27    pub fn parse(value: &str) -> anyhow::Result<Self> {
28        match value {
29            "anthropic" => Ok(Self::Anthropic),
30            "responses" => Ok(Self::Responses),
31            "chatcompletion" => Ok(Self::Chatcompletion),
32            _ => anyhow::bail!("unknown provider type in session: {value}"),
33        }
34    }
35}
36
37#[derive(Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct ProviderConfig {
40    #[serde(rename = "type")]
41    pub kind: ProviderKind,
42    pub base_url: Option<String>,
43    pub model: String,
44    pub api_key_env: String,
45    pub api_key: Option<String>,
46    pub headers: BTreeMap<String, String>,
47    pub max_tokens: u32,
48    pub request: BTreeMap<String, serde_json::Value>,
49}
50
51impl Default for ProviderConfig {
52    fn default() -> Self {
53        Self {
54            kind: ProviderKind::Responses,
55            base_url: None,
56            model: "gpt-5.6".into(),
57            api_key_env: "OPENAI_API_KEY".into(),
58            api_key: None,
59            headers: BTreeMap::new(),
60            max_tokens: 8192,
61            request: BTreeMap::new(),
62        }
63    }
64}
65
66impl ProviderConfig {
67    pub fn resolve_api_key(&self) -> Result<String> {
68        self.resolve_api_key_with(|name| std::env::var(name).ok())
69    }
70
71    pub fn resolve_api_key_with(
72        &self,
73        get_env: impl FnOnce(&str) -> Option<String>,
74    ) -> Result<String> {
75        if let Some(api_key) = self.api_key.as_ref().filter(|key| !key.is_empty()) {
76            return Ok(api_key.clone());
77        }
78        get_env(&self.api_key_env).ok_or_else(|| {
79            anyhow::anyhow!(
80                "provider authentication is not configured; set api_key in the selected provider, set {}, or update ~/.config/a/config.toml",
81                self.api_key_env
82            )
83        })
84    }
85}
86
87impl fmt::Debug for ProviderConfig {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter
90            .debug_struct("ProviderConfig")
91            .field("kind", &self.kind)
92            .field("base_url", &self.base_url)
93            .field("model", &self.model)
94            .field("api_key_env", &self.api_key_env)
95            .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
96            .field("headers", &self.headers)
97            .field("max_tokens", &self.max_tokens)
98            .field("request", &self.request)
99            .finish()
100    }
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104#[serde(default)]
105pub struct ModelProfile {
106    pub provider: String,
107    pub model: String,
108    pub effort: Option<String>,
109    pub efforts: Vec<String>,
110    pub context_window: Option<u64>,
111    pub max_tokens: Option<u32>,
112    pub headers: BTreeMap<String, String>,
113    pub request: BTreeMap<String, serde_json::Value>,
114}
115
116#[derive(Debug, Clone)]
117pub struct ModelSelection {
118    pub name: String,
119    pub provider_name: String,
120    pub provider: ProviderConfig,
121    pub effort: Option<String>,
122    pub efforts: Vec<String>,
123    pub context_window: Option<u64>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(default)]
128pub struct UiConfig {
129    pub show_reasoning: bool,
130    pub reasoning_toggle: String,
131    pub tool_input_max_bytes: usize,
132    pub tool_output_max_bytes: usize,
133    pub tool_output_max_lines: usize,
134    pub tool_live_output_lines: usize,
135}
136
137impl Default for UiConfig {
138    fn default() -> Self {
139        Self {
140            show_reasoning: false,
141            reasoning_toggle: "ctrl-o".into(),
142            tool_input_max_bytes: 2048,
143            tool_output_max_bytes: 8192,
144            tool_output_max_lines: 16,
145            tool_live_output_lines: 6,
146        }
147    }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(default)]
152pub struct ToolsConfig {
153    pub bash_timeout_seconds: u64,
154    pub max_parallel: usize,
155    pub max_output_bytes: usize,
156}
157
158impl Default for ToolsConfig {
159    fn default() -> Self {
160        Self {
161            bash_timeout_seconds: 120,
162            max_parallel: 8,
163            max_output_bytes: 65_536,
164        }
165    }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ContextConfig {
171    pub shell_history_count: usize,
172    pub stdin_max_bytes: usize,
173    pub read_max_lines: usize,
174}
175
176impl Default for ContextConfig {
177    fn default() -> Self {
178        Self {
179            shell_history_count: 5,
180            stdin_max_bytes: 131_072,
181            read_max_lines: 1000,
182        }
183    }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct SessionConfig {
189    pub max_agent_cycles: usize,
190    pub shell_history_limit: usize,
191    pub input_history_limit: usize,
192}
193
194impl Default for SessionConfig {
195    fn default() -> Self {
196        Self {
197            max_agent_cycles: 50,
198            shell_history_limit: 5000,
199            input_history_limit: 1000,
200        }
201    }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default)]
206pub struct Config {
207    pub default_model: String,
208    pub providers: BTreeMap<String, ProviderConfig>,
209    pub models: BTreeMap<String, ModelProfile>,
210    pub ui: UiConfig,
211    pub tools: ToolsConfig,
212    pub context: ContextConfig,
213    pub session: SessionConfig,
214}
215
216impl Default for Config {
217    fn default() -> Self {
218        let default_model = ModelProfile {
219            provider: "openai".into(),
220            model: "gpt-5.6".into(),
221            effort: Some("medium".into()),
222            efforts: canonical_efforts(),
223            context_window: Some(1_050_000),
224            ..ModelProfile::default()
225        };
226        Self {
227            default_model: "default".into(),
228            providers: BTreeMap::from([("openai".into(), ProviderConfig::default())]),
229            models: BTreeMap::from([("default".into(), default_model)]),
230            ui: UiConfig::default(),
231            tools: ToolsConfig::default(),
232            context: ContextConfig::default(),
233            session: SessionConfig::default(),
234        }
235    }
236}
237
238impl Config {
239    pub fn ensure_user_config(home: &Path) -> Result<Option<PathBuf>> {
240        let path = home.join(".config/a/config.toml");
241        if path.exists() {
242            return Ok(None);
243        }
244        let directory = path.parent().context("config path has no parent")?;
245        fs::create_dir_all(directory)
246            .with_context(|| format!("create config directory {}", directory.display()))?;
247        let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
248        temporary.write_all(include_bytes!("../config.example.toml"))?;
249        temporary.as_file().sync_all()?;
250        match temporary.persist_noclobber(&path) {
251            Ok(_) => Ok(Some(path)),
252            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
253            Err(error) => Err(error.error)
254                .with_context(|| format!("create initial config {}", path.display())),
255        }
256    }
257
258    pub fn load_from(cwd: &Path, home: &Path) -> Result<Self> {
259        let global = home.join(".config/a/config.toml");
260        let project = cwd.join(".a/config.toml");
261        let mut merged = toml::Value::Table(Default::default());
262        for path in [global, project] {
263            if path.is_file() {
264                let source = fs::read_to_string(&path)
265                    .with_context(|| format!("read config {}", path.display()))?;
266                let value = toml::from_str::<toml::Value>(&source)
267                    .with_context(|| format!("parse config {}", path.display()))?;
268                merge_toml(&mut merged, value);
269            }
270        }
271
272        if merged.get("provider").is_some() {
273            anyhow::bail!(
274                "legacy [provider] configuration is no longer supported; define [providers.<name>], [models.<name>], and default_model"
275            );
276        }
277        let explicit_api_key_envs = merged
278            .get("providers")
279            .and_then(toml::Value::as_table)
280            .into_iter()
281            .flat_map(|providers| providers.iter())
282            .filter_map(|(name, value)| value.get("api_key_env").map(|_| name.clone()))
283            .collect::<BTreeSet<_>>();
284        let mut config: Self = merged.try_into().context("decode merged configuration")?;
285        for (name, provider) in &mut config.providers {
286            if !explicit_api_key_envs.contains(name) && provider.kind == ProviderKind::Anthropic {
287                provider.api_key_env = "ANTHROPIC_API_KEY".into();
288            }
289        }
290        if config.tools.max_parallel == 0 {
291            anyhow::bail!("tools.max_parallel must be greater than zero");
292        }
293        config.validate_models()?;
294        Ok(config)
295    }
296
297    pub fn load(cwd: &Path) -> Result<Self> {
298        let home = std::env::var_os("HOME")
299            .map(PathBuf::from)
300            .context("HOME is not set")?;
301        Self::load_from(cwd, &home)
302    }
303
304    pub fn model_names(&self) -> Vec<&str> {
305        self.models.keys().map(String::as_str).collect()
306    }
307
308    pub fn resolve_model(
309        &self,
310        name: Option<&str>,
311        effort_override: Option<&str>,
312    ) -> Result<ModelSelection> {
313        let name = name.unwrap_or(&self.default_model);
314        let profile = self
315            .models
316            .get(name)
317            .with_context(|| format!("model profile not found: {name}"))?;
318        let mut provider = self
319            .providers
320            .get(&profile.provider)
321            .cloned()
322            .with_context(|| {
323                format!(
324                    "provider '{}' referenced by model '{name}' was not found",
325                    profile.provider
326                )
327            })?;
328        provider.model = profile.model.clone();
329        if let Some(max_tokens) = profile.max_tokens {
330            provider.max_tokens = max_tokens;
331        }
332        provider.headers.extend(profile.headers.clone());
333        provider.request.extend(profile.request.clone());
334
335        let effort = effort_override.or(profile.effort.as_deref());
336        if let Some(effort) = effort {
337            validate_effort(effort)?;
338            if !profile.efforts.iter().any(|candidate| candidate == effort) {
339                anyhow::bail!("effort '{effort}' is not configured for model '{name}'");
340            }
341            apply_effort(&mut provider, effort)?;
342        }
343        Ok(ModelSelection {
344            name: name.into(),
345            provider_name: profile.provider.clone(),
346            provider,
347            effort: effort.map(str::to_owned),
348            efforts: profile.efforts.clone(),
349            context_window: profile.context_window,
350        })
351    }
352
353    pub fn resolve_session_model(
354        &self,
355        profile: Option<&str>,
356        provider_type: &str,
357        model: &str,
358        effort: Option<&str>,
359    ) -> Result<ModelSelection> {
360        if let Some(profile) = profile {
361            return self.resolve_model(Some(profile), effort);
362        }
363        let kind = ProviderKind::parse(provider_type)?;
364        for name in self.models.keys() {
365            let selection = self.resolve_model(Some(name), None)?;
366            if selection.provider.kind == kind && selection.provider.model == model {
367                return self.resolve_model(Some(name), effort);
368            }
369        }
370        anyhow::bail!(
371            "session model {provider_type}/{model} does not match a configured model profile"
372        )
373    }
374
375    fn validate_models(&self) -> Result<()> {
376        if self.models.is_empty() {
377            anyhow::bail!("at least one [models.<name>] profile is required");
378        }
379        if !self.models.contains_key(&self.default_model) {
380            anyhow::bail!("default_model '{}' was not found", self.default_model);
381        }
382        for (name, profile) in &self.models {
383            if profile.provider.is_empty() || profile.model.is_empty() {
384                anyhow::bail!("model '{name}' requires provider and model");
385            }
386            let provider = self.providers.get(&profile.provider).with_context(|| {
387                format!(
388                    "provider '{}' referenced by model '{name}' was not found",
389                    profile.provider
390                )
391            })?;
392            let max_tokens = profile.max_tokens.unwrap_or(provider.max_tokens);
393            if max_tokens == 0 {
394                anyhow::bail!("max_tokens must be greater than zero for model '{name}'");
395            }
396            if let Some(context_window) = profile.context_window
397                && context_window <= u64::from(max_tokens)
398            {
399                anyhow::bail!(
400                    "context_window ({context_window}) must be greater than max_tokens ({max_tokens}) for model '{name}'"
401                );
402            }
403            for effort in profile.efforts.iter().chain(profile.effort.iter()) {
404                validate_effort(effort)?;
405            }
406            if let Some(effort) = &profile.effort
407                && !profile.efforts.iter().any(|candidate| candidate == effort)
408            {
409                anyhow::bail!(
410                    "default effort '{effort}' is not listed in efforts for model '{name}'"
411                );
412            }
413        }
414        Ok(())
415    }
416}
417
418fn canonical_efforts() -> Vec<String> {
419    ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
420        .into_iter()
421        .map(str::to_owned)
422        .collect()
423}
424
425fn validate_effort(effort: &str) -> Result<()> {
426    if canonical_efforts()
427        .iter()
428        .any(|candidate| candidate == effort)
429    {
430        Ok(())
431    } else {
432        anyhow::bail!("unknown effort '{effort}'")
433    }
434}
435
436fn apply_effort(provider: &mut ProviderConfig, effort: &str) -> Result<()> {
437    match provider.kind {
438        ProviderKind::Responses => {
439            insert_nested_request_value(&mut provider.request, "reasoning", "effort", effort)
440        }
441        ProviderKind::Chatcompletion => {
442            provider.request.insert(
443                "reasoning_effort".into(),
444                serde_json::Value::String(effort.into()),
445            );
446            Ok(())
447        }
448        ProviderKind::Anthropic => {
449            insert_nested_request_value(&mut provider.request, "output_config", "effort", effort)
450        }
451    }
452}
453
454fn insert_nested_request_value(
455    request: &mut BTreeMap<String, serde_json::Value>,
456    object_key: &str,
457    field: &str,
458    value: &str,
459) -> Result<()> {
460    let object = request
461        .entry(object_key.into())
462        .or_insert_with(|| serde_json::json!({}));
463    let object = object
464        .as_object_mut()
465        .with_context(|| format!("request.{object_key} must be an object"))?;
466    object.insert(field.into(), serde_json::Value::String(value.into()));
467    Ok(())
468}
469
470fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
471    match (base, overlay) {
472        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
473            for (key, value) in overlay {
474                match base.get_mut(&key) {
475                    Some(current) => merge_toml(current, value),
476                    None => {
477                        base.insert(key, value);
478                    }
479                }
480            }
481        }
482        (base, overlay) => *base = overlay,
483    }
484}