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