Skip to main content

a3s_code_core/config/
loader.rs

1use super::provider::{
2    apply_model_caps, ModelConfig, ModelCost, ModelLimit, ModelModalities, ProviderConfig,
3};
4use super::{AutoDelegationConfig, CodeConfig, StorageBackend};
5use crate::error::{CodeError, Result};
6use crate::llm::LlmConfig;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10// ============================================================================
11// ACL Parsing Helpers
12// ============================================================================
13
14fn acl_attr<'a>(block: &'a a3s_acl::Block, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
15    keys.iter().find_map(|key| block.attributes.get(*key))
16}
17
18fn acl_string(value: &a3s_acl::Value) -> Option<String> {
19    match value {
20        a3s_acl::Value::String(s) => Some(s.clone()),
21        a3s_acl::Value::Call(name, args) if name == "env" => {
22            let var_name = args.first().and_then(acl_string)?;
23            std::env::var(var_name).ok()
24        }
25        _ => None,
26    }
27}
28
29fn acl_string_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
30    acl_attr(block, keys).and_then(acl_string)
31}
32
33fn acl_label_or_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
34    block
35        .labels
36        .first()
37        .cloned()
38        .or_else(|| acl_string_attr(block, keys))
39}
40
41fn acl_bool_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<bool> {
42    match acl_attr(block, keys) {
43        Some(a3s_acl::Value::Bool(value)) => Some(*value),
44        _ => None,
45    }
46}
47
48fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
49    match acl_attr(block, keys) {
50        Some(a3s_acl::Value::Number(value)) if *value >= 0.0 => Some(*value as usize),
51        _ => None,
52    }
53}
54
55fn acl_f32_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<f32> {
56    match acl_attr(block, keys) {
57        Some(a3s_acl::Value::Number(value)) => Some(*value as f32),
58        _ => None,
59    }
60}
61
62fn parse_auto_delegation_block(
63    block: &a3s_acl::Block,
64    base: &AutoDelegationConfig,
65) -> AutoDelegationConfig {
66    let mut config = base.clone();
67    if let Some(enabled) = acl_bool_attr(block, &["enabled"]) {
68        config.enabled = enabled;
69    }
70    if let Some(auto_parallel) =
71        acl_bool_attr(block, &["auto_parallel", "autoParallel", "parallel"])
72    {
73        config.auto_parallel = auto_parallel;
74    }
75    if let Some(min_confidence) = acl_f32_attr(block, &["min_confidence", "minConfidence"]) {
76        config.min_confidence = min_confidence.clamp(0.0, 1.0);
77    }
78    if let Some(max_tasks) = acl_usize_attr(block, &["max_tasks", "maxTasks"]) {
79        config.max_tasks = max_tasks.max(1);
80    }
81    config
82}
83
84fn acl_u32(value: &a3s_acl::Value) -> Option<u32> {
85    match value {
86        a3s_acl::Value::Number(value) if *value >= 0.0 => {
87            Some((*value as usize).min(u32::MAX as usize) as u32)
88        }
89        _ => None,
90    }
91}
92
93fn acl_object_u32_attr(value: &a3s_acl::Value, key: &str) -> Option<u32> {
94    match value {
95        a3s_acl::Value::Object(pairs) => pairs
96            .iter()
97            .find_map(|(candidate, value)| (candidate == key).then(|| acl_u32(value)).flatten()),
98        _ => None,
99    }
100}
101
102fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
103    let value = acl_attr(block, keys)?;
104    match value {
105        a3s_acl::Value::List(items) => Some(
106            items
107                .iter()
108                .filter_map(acl_string)
109                .map(PathBuf::from)
110                .collect(),
111        ),
112        _ => acl_string(value).map(|s| vec![PathBuf::from(s)]),
113    }
114}
115
116// ============================================================================
117// CodeConfig Implementation
118// ============================================================================
119
120impl CodeConfig {
121    /// Create a new empty configuration
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Load configuration from an ACL-compatible config file.
127    ///
128    /// `.acl` is the only supported config file extension. JSON and legacy
129    /// `.hcl` config files are not supported.
130    pub fn from_file(path: &Path) -> Result<Self> {
131        let content = std::fs::read_to_string(path).map_err(|e| {
132            CodeError::Config(format!(
133                "Failed to read config file {}: {}",
134                path.display(),
135                e
136            ))
137        })?;
138
139        Self::from_acl(&content).map_err(|e| {
140            CodeError::Config(format!(
141                "Failed to parse ACL config {}: {}",
142                path.display(),
143                e
144            ))
145        })
146    }
147
148    /// Parse configuration from an ACL string.
149    ///
150    /// ACL (Agent Configuration Language) uses labeled blocks like
151    /// `providers "openai" { }`.
152    pub fn from_acl(content: &str) -> Result<Self> {
153        use a3s_acl::parse_acl;
154
155        let doc = parse_acl(content)
156            .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
157
158        let mut config = Self::default();
159
160        for block in doc.blocks {
161            match block.name.as_str() {
162                "default_model" => {
163                    // ACL: default_model = "openai/gpt-4" or just "openai/gpt-4" as label
164                    if let Some(default_model) = acl_label_or_attr(&block, &["default_model"]) {
165                        config.default_model = Some(default_model);
166                    }
167                }
168                "storage_backend" => {
169                    if let Some(backend) = acl_string_attr(&block, &["storage_backend"]) {
170                        config.storage_backend = match backend.to_ascii_lowercase().as_str() {
171                            "memory" => StorageBackend::Memory,
172                            "custom" => StorageBackend::Custom,
173                            _ => StorageBackend::File,
174                        };
175                    }
176                }
177                "sessions_dir" => {
178                    if let Some(path) = acl_string_attr(&block, &["sessions_dir"]) {
179                        config.sessions_dir = Some(PathBuf::from(path));
180                    }
181                }
182                "storage_url" => {
183                    if let Some(storage_url) = acl_string_attr(&block, &["storage_url"]) {
184                        config.storage_url = Some(storage_url);
185                    }
186                }
187                "skill_dirs" | "skills" => {
188                    if let Some(paths) = acl_path_list_attr(&block, &["skill_dirs", "skills"]) {
189                        config.skill_dirs = paths;
190                    }
191                }
192                "agent_dirs" => {
193                    if let Some(paths) = acl_path_list_attr(&block, &["agent_dirs"]) {
194                        config.agent_dirs = paths;
195                    }
196                }
197                "max_tool_rounds" => {
198                    if let Some(max_tool_rounds) = acl_usize_attr(&block, &["max_tool_rounds"]) {
199                        config.max_tool_rounds = Some(max_tool_rounds);
200                    }
201                }
202                "max_parallel_tasks" => {
203                    if let Some(max_parallel_tasks) =
204                        acl_usize_attr(&block, &["max_parallel_tasks"])
205                    {
206                        config.max_parallel_tasks = Some(max_parallel_tasks);
207                    }
208                }
209                "auto_parallel" | "auto_parallel_enabled" => {
210                    if let Some(auto_parallel) =
211                        acl_bool_attr(&block, &["auto_parallel", "auto_parallel_enabled"])
212                    {
213                        config.auto_parallel = Some(auto_parallel);
214                    }
215                }
216                "auto_delegation" => {
217                    config.auto_delegation =
218                        parse_auto_delegation_block(&block, &config.auto_delegation);
219                }
220                "thinking_budget" => {
221                    if let Some(thinking_budget) = acl_usize_attr(&block, &["thinking_budget"]) {
222                        config.thinking_budget = Some(thinking_budget);
223                    }
224                }
225                "providers" => {
226                    let provider_name = block.labels.first().cloned().ok_or_else(|| {
227                        CodeError::Config(
228                            "providers block requires a label (e.g., providers \"openai\" { ... })"
229                                .into(),
230                        )
231                    })?;
232
233                    let mut provider = ProviderConfig {
234                        name: provider_name.clone(),
235                        api_key: None,
236                        base_url: None,
237                        headers: HashMap::new(),
238                        session_id_header: None,
239                        models: Vec::new(),
240                    };
241
242                    for (key, value) in &block.attributes {
243                        match key.as_str() {
244                            "apiKey" | "api_key" => {
245                                if let Some(api_key) = acl_string(value) {
246                                    provider.api_key = Some(api_key);
247                                }
248                            }
249                            "baseUrl" | "base_url" => {
250                                if let Some(base_url) = acl_string(value) {
251                                    provider.base_url = Some(base_url);
252                                }
253                            }
254                            "sessionIdHeader" | "session_id_header" => {
255                                if let Some(header) = acl_string(value) {
256                                    provider.session_id_header = Some(header);
257                                }
258                            }
259                            _ => {}
260                        }
261                    }
262
263                    // Process nested models blocks
264                    for model_block in &block.blocks {
265                        if model_block.name == "models" {
266                            let model_name =
267                                model_block.labels.first().cloned().ok_or_else(|| {
268                                    CodeError::Config(
269                                        "models block requires a label (e.g., models \"gpt-4\" { ... })"
270                                            .into(),
271                                    )
272                                })?;
273
274                            let mut model = ModelConfig {
275                                id: model_name.clone(),
276                                name: model_name.clone(),
277                                family: String::new(),
278                                api_key: None,
279                                base_url: None,
280                                headers: HashMap::new(),
281                                session_id_header: None,
282                                attachment: false,
283                                reasoning: false,
284                                tool_call: true,
285                                temperature: true,
286                                release_date: None,
287                                modalities: ModelModalities::default(),
288                                cost: ModelCost::default(),
289                                limit: ModelLimit::default(),
290                            };
291
292                            for (key, value) in &model_block.attributes {
293                                match key.as_str() {
294                                    "name" => {
295                                        if let Some(s) = acl_string(value) {
296                                            model.name = s;
297                                        }
298                                    }
299                                    "family" => {
300                                        if let Some(s) = acl_string(value) {
301                                            model.family = s;
302                                        }
303                                    }
304                                    "apiKey" | "api_key" => {
305                                        if let Some(api_key) = acl_string(value) {
306                                            model.api_key = Some(api_key);
307                                        }
308                                    }
309                                    "baseUrl" | "base_url" => {
310                                        if let Some(base_url) = acl_string(value) {
311                                            model.base_url = Some(base_url);
312                                        }
313                                    }
314                                    "sessionIdHeader" | "session_id_header" => {
315                                        if let Some(header) = acl_string(value) {
316                                            model.session_id_header = Some(header);
317                                        }
318                                    }
319                                    "attachment" => {
320                                        model.attachment =
321                                            acl_bool_attr(model_block, &["attachment"])
322                                                .unwrap_or(model.attachment);
323                                    }
324                                    "reasoning" => {
325                                        model.reasoning =
326                                            acl_bool_attr(model_block, &["reasoning"])
327                                                .unwrap_or(model.reasoning);
328                                    }
329                                    "toolCall" | "tool_call" => {
330                                        model.tool_call =
331                                            acl_bool_attr(model_block, &["toolCall", "tool_call"])
332                                                .unwrap_or(model.tool_call);
333                                    }
334                                    "temperature" => {
335                                        model.temperature =
336                                            acl_bool_attr(model_block, &["temperature"])
337                                                .unwrap_or(model.temperature);
338                                    }
339                                    "releaseDate" | "release_date" => {
340                                        if let Some(release_date) = acl_string(value) {
341                                            model.release_date = Some(release_date);
342                                        }
343                                    }
344                                    "maxTokens" => {
345                                        tracing::warn!(
346                                            provider = %provider.name,
347                                            model = %model.id,
348                                            field = "maxTokens",
349                                            "Flat ACL model token limit fields are deprecated; use limit = {{ output = ..., context = ... }}"
350                                        );
351                                        if let Some(output) = acl_u32(value) {
352                                            model.limit.output = output;
353                                        }
354                                    }
355                                    "contextTokens" => {
356                                        tracing::warn!(
357                                            provider = %provider.name,
358                                            model = %model.id,
359                                            field = "contextTokens",
360                                            "Flat ACL model token limit fields are deprecated; use limit = {{ output = ..., context = ... }}"
361                                        );
362                                        if let Some(context) = acl_u32(value) {
363                                            model.limit.context = context;
364                                        }
365                                    }
366                                    "limit" => {
367                                        if let Some(output) = acl_object_u32_attr(value, "output") {
368                                            model.limit.output = output;
369                                        }
370                                        if let Some(context) = acl_object_u32_attr(value, "context")
371                                        {
372                                            model.limit.context = context;
373                                        }
374                                    }
375                                    _ => {}
376                                }
377                            }
378
379                            provider.models.push(model);
380                        }
381                    }
382
383                    config.providers.push(provider);
384                }
385                _ => {
386                    // Other top-level blocks are not mapped by the lightweight
387                    // ACL loader yet (queue, search, memory, MCP, etc.).
388                }
389            }
390        }
391
392        if let Some(auto_parallel) = config.auto_parallel {
393            config.auto_delegation.auto_parallel = auto_parallel;
394        }
395
396        Ok(config)
397    }
398
399    /// Find a provider by name
400    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
401        self.providers.iter().find(|p| p.name == name)
402    }
403
404    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
405    pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
406        let default = self.default_model.as_ref()?;
407        let (provider_name, _) = default.split_once('/')?;
408        self.find_provider(provider_name)
409    }
410
411    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
412    pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
413        let default = self.default_model.as_ref()?;
414        let (provider_name, model_id) = default.split_once('/')?;
415        let provider = self.find_provider(provider_name)?;
416        let model = provider.find_model(model_id)?;
417        Some((provider, model))
418    }
419
420    /// Get LlmConfig for the default provider and model
421    ///
422    /// Returns None if default provider/model is not configured or API key is missing.
423    pub fn default_llm_config(&self) -> Option<LlmConfig> {
424        let (provider, model) = self.default_model_config()?;
425        let api_key = provider.get_api_key(model)?;
426        let base_url = provider.get_base_url(model);
427        let headers = provider.get_headers(model);
428        let session_id_header = provider.get_session_id_header(model);
429
430        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
431        if let Some(url) = base_url {
432            config = config.with_base_url(url);
433        }
434        if !headers.is_empty() {
435            config = config.with_headers(headers);
436        }
437        if let Some(header_name) = session_id_header {
438            config = config.with_session_id_header(header_name);
439        }
440        config = apply_model_caps(config, model, self.thinking_budget);
441        Some(config)
442    }
443
444    /// Get LlmConfig for a specific provider and model
445    ///
446    /// Returns None if provider/model is not found or API key is missing.
447    pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
448        let provider = self.find_provider(provider_name)?;
449        let model = provider.find_model(model_id)?;
450        let api_key = provider.get_api_key(model)?;
451        let base_url = provider.get_base_url(model);
452        let headers = provider.get_headers(model);
453        let session_id_header = provider.get_session_id_header(model);
454
455        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
456        if let Some(url) = base_url {
457            config = config.with_base_url(url);
458        }
459        if !headers.is_empty() {
460            config = config.with_headers(headers);
461        }
462        if let Some(header_name) = session_id_header {
463            config = config.with_session_id_header(header_name);
464        }
465        config = apply_model_caps(config, model, self.thinking_budget);
466        Some(config)
467    }
468
469    /// List all available models across all providers
470    pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
471        self.providers
472            .iter()
473            .flat_map(|p| p.models.iter().map(move |m| (p, m)))
474            .collect()
475    }
476
477    /// Add a skill directory
478    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
479        self.skill_dirs.push(dir.into());
480        self
481    }
482
483    /// Add an agent directory
484    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
485        self.agent_dirs.push(dir.into());
486        self
487    }
488
489    /// Check if any directories are configured
490    pub fn has_directories(&self) -> bool {
491        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
492    }
493
494    /// Check if provider configuration is available
495    pub fn has_providers(&self) -> bool {
496        !self.providers.is_empty()
497    }
498}