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, OsConfig, StorageBackend};
5use crate::error::{CodeError, Result};
6use crate::llm::LlmConfig;
7use crate::memory::MemoryConfig;
8use a3s_memory::{PrunePolicy, RelevanceConfig};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12// ============================================================================
13// ACL Parsing Helpers
14// ============================================================================
15
16fn acl_attr<'a>(block: &'a a3s_acl::Block, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
17    keys.iter().find_map(|key| block.attributes.get(*key))
18}
19
20fn acl_string(value: &a3s_acl::Value) -> Option<String> {
21    match value {
22        a3s_acl::Value::String(s) => Some(s.clone()),
23        a3s_acl::Value::Call(name, args) if name == "env" => {
24            let var_name = args.first().and_then(acl_string)?;
25            std::env::var(var_name).ok()
26        }
27        _ => None,
28    }
29}
30
31fn acl_string_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
32    acl_attr(block, keys).and_then(acl_string)
33}
34
35fn acl_label_or_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
36    block
37        .labels
38        .first()
39        .cloned()
40        .or_else(|| acl_string_attr(block, keys))
41}
42
43fn acl_bool_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<bool> {
44    match acl_attr(block, keys) {
45        Some(a3s_acl::Value::Bool(value)) => Some(*value),
46        _ => None,
47    }
48}
49
50fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
51    match acl_attr(block, keys) {
52        Some(a3s_acl::Value::Number(value)) if *value >= 0.0 => Some(*value as usize),
53        _ => None,
54    }
55}
56
57fn acl_f32_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<f32> {
58    match acl_attr(block, keys) {
59        Some(a3s_acl::Value::Number(value)) => Some(*value as f32),
60        _ => None,
61    }
62}
63
64fn parse_auto_delegation_block(
65    block: &a3s_acl::Block,
66    base: &AutoDelegationConfig,
67) -> AutoDelegationConfig {
68    let mut config = base.clone();
69    if let Some(enabled) = acl_bool_attr(block, &["enabled"]) {
70        config.enabled = enabled;
71    }
72    if let Some(auto_parallel) =
73        acl_bool_attr(block, &["auto_parallel", "autoParallel", "parallel"])
74    {
75        config.auto_parallel = auto_parallel;
76    }
77    if let Some(allow_manual_delegation) = acl_bool_attr(
78        block,
79        &[
80            "allow_manual_delegation",
81            "allowManualDelegation",
82            "manual_delegation",
83            "manualDelegation",
84        ],
85    ) {
86        config.allow_manual_delegation = allow_manual_delegation;
87    }
88    if let Some(min_confidence) = acl_f32_attr(block, &["min_confidence", "minConfidence"]) {
89        config.min_confidence = min_confidence.clamp(0.0, 1.0);
90    }
91    if let Some(max_tasks) = acl_usize_attr(block, &["max_tasks", "maxTasks"]) {
92        config.max_tasks = max_tasks.max(1);
93    }
94    config
95}
96
97fn parse_memory_block(block: &a3s_acl::Block, base: Option<&MemoryConfig>) -> MemoryConfig {
98    let mut config = base.cloned().unwrap_or_default();
99
100    if let Some(max_short_term) = acl_usize_attr(block, &["max_short_term", "maxShortTerm"]) {
101        config.max_short_term = max_short_term;
102    }
103    if let Some(max_working) = acl_usize_attr(block, &["max_working", "maxWorking"]) {
104        config.max_working = max_working;
105    }
106    if let Some(prune_interval_secs) =
107        acl_usize_attr(block, &["prune_interval_secs", "pruneIntervalSecs"])
108    {
109        config.prune_interval_secs = prune_interval_secs as u64;
110    }
111    if let Some(llm_extraction) = acl_bool_attr(block, &["llm_extraction", "llmExtraction"]) {
112        config.llm_extraction = llm_extraction;
113    }
114    if let Some(max_items) = acl_usize_attr(
115        block,
116        &["llm_extraction_max_items", "llmExtractionMaxItems"],
117    ) {
118        config.llm_extraction_max_items = max_items;
119    }
120    if let Some(max_input_chars) = acl_usize_attr(
121        block,
122        &[
123            "llm_extraction_max_input_chars",
124            "llmExtractionMaxInputChars",
125        ],
126    ) {
127        config.llm_extraction_max_input_chars = max_input_chars;
128    }
129
130    if let Some(relevance) = acl_attr(block, &["relevance"]) {
131        config.relevance = parse_relevance_value(relevance, &config.relevance);
132    }
133
134    if let Some(prune_policy) = acl_attr(block, &["prune", "prune_policy", "prunePolicy"]) {
135        config.prune_policy = Some(parse_prune_policy_value(
136            prune_policy,
137            config.prune_policy.as_ref(),
138        ));
139    }
140
141    config
142}
143
144fn parse_relevance_value(value: &a3s_acl::Value, base: &RelevanceConfig) -> RelevanceConfig {
145    let mut config = base.clone();
146    if let Some(decay_days) = acl_object_f32_attr(value, &["decay_days", "decayDays"]) {
147        config.decay_days = decay_days.max(0.1);
148    }
149    if let Some(importance_weight) =
150        acl_object_f32_attr(value, &["importance_weight", "importanceWeight"])
151    {
152        config.importance_weight = importance_weight.max(0.0);
153    }
154    if let Some(recency_weight) = acl_object_f32_attr(value, &["recency_weight", "recencyWeight"]) {
155        config.recency_weight = recency_weight.max(0.0);
156    }
157    config
158}
159
160fn parse_prune_policy_value(value: &a3s_acl::Value, base: Option<&PrunePolicy>) -> PrunePolicy {
161    let mut policy = base.cloned().unwrap_or_default();
162    if let Some(max_age_days) = acl_object_u32_attr(value, &["max_age_days", "maxAgeDays"]) {
163        policy.max_age_days = max_age_days;
164    }
165    if let Some(min_importance) =
166        acl_object_f32_attr(value, &["min_importance_to_keep", "minImportanceToKeep"])
167    {
168        policy.min_importance_to_keep = min_importance.clamp(0.0, 1.0);
169    }
170    if let Some(max_items) = acl_object_usize_attr(value, &["max_items", "maxItems"]) {
171        policy.max_items = max_items;
172    }
173    policy
174}
175
176fn acl_object_attr<'a>(value: &'a a3s_acl::Value, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
177    match value {
178        a3s_acl::Value::Object(pairs) => keys.iter().find_map(|key| {
179            pairs
180                .iter()
181                .find_map(|(candidate, value)| (candidate == key).then_some(value))
182        }),
183        _ => None,
184    }
185}
186
187fn acl_f32(value: &a3s_acl::Value) -> Option<f32> {
188    match value {
189        a3s_acl::Value::Number(value) => Some(*value as f32),
190        _ => None,
191    }
192}
193
194fn acl_usize(value: &a3s_acl::Value) -> Option<usize> {
195    match value {
196        a3s_acl::Value::Number(value) if *value >= 0.0 => Some(*value as usize),
197        _ => None,
198    }
199}
200
201fn acl_u32(value: &a3s_acl::Value) -> Option<u32> {
202    match value {
203        a3s_acl::Value::Number(value) if *value >= 0.0 => {
204            Some((*value as usize).min(u32::MAX as usize) as u32)
205        }
206        _ => None,
207    }
208}
209
210fn acl_object_f32_attr(value: &a3s_acl::Value, keys: &[&str]) -> Option<f32> {
211    acl_object_attr(value, keys).and_then(acl_f32)
212}
213
214fn acl_object_usize_attr(value: &a3s_acl::Value, keys: &[&str]) -> Option<usize> {
215    acl_object_attr(value, keys).and_then(acl_usize)
216}
217
218fn acl_object_u32_attr(value: &a3s_acl::Value, keys: &[&str]) -> Option<u32> {
219    acl_object_attr(value, keys).and_then(acl_u32)
220}
221
222fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
223    let value = acl_attr(block, keys)?;
224    match value {
225        a3s_acl::Value::List(items) => Some(
226            items
227                .iter()
228                .filter_map(acl_string)
229                .map(PathBuf::from)
230                .collect(),
231        ),
232        _ => acl_string(value).map(|s| vec![PathBuf::from(s)]),
233    }
234}
235
236// ============================================================================
237// CodeConfig Implementation
238// ============================================================================
239
240impl CodeConfig {
241    /// Create a new empty configuration
242    pub fn new() -> Self {
243        Self::default()
244    }
245
246    /// Load configuration from an ACL-compatible config file.
247    ///
248    /// `.acl` is the only supported config file extension. JSON and legacy
249    /// `.hcl` config files are not supported.
250    pub fn from_file(path: &Path) -> Result<Self> {
251        let content = std::fs::read_to_string(path).map_err(|e| {
252            CodeError::Config(format!(
253                "Failed to read config file {}: {}",
254                path.display(),
255                e
256            ))
257        })?;
258
259        Self::from_acl(&content).map_err(|e| {
260            CodeError::Config(format!(
261                "Failed to parse ACL config {}: {}",
262                path.display(),
263                e
264            ))
265        })
266    }
267
268    /// Parse configuration from an ACL string.
269    ///
270    /// ACL (Agent Configuration Language) uses labeled blocks like
271    /// `providers "openai" { }`.
272    pub fn from_acl(content: &str) -> Result<Self> {
273        use a3s_acl::parse_acl;
274
275        let doc = parse_acl(content)
276            .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
277
278        let mut config = Self::default();
279
280        for block in doc.blocks {
281            match block.name.as_str() {
282                "default_model" => {
283                    // ACL: default_model = "openai/gpt-4" or just "openai/gpt-4" as label
284                    if let Some(default_model) = acl_label_or_attr(&block, &["default_model"]) {
285                        config.default_model = Some(default_model);
286                    }
287                }
288                "storage_backend" => {
289                    if let Some(backend) = acl_string_attr(&block, &["storage_backend"]) {
290                        config.storage_backend = match backend.to_ascii_lowercase().as_str() {
291                            "memory" => StorageBackend::Memory,
292                            "custom" => StorageBackend::Custom,
293                            _ => StorageBackend::File,
294                        };
295                    }
296                }
297                "sessions_dir" => {
298                    if let Some(path) = acl_string_attr(&block, &["sessions_dir"]) {
299                        config.sessions_dir = Some(PathBuf::from(path));
300                    }
301                }
302                "memory_dir" | "memoryDir" => {
303                    if let Some(path) = acl_string_attr(&block, &["memory_dir", "memoryDir"]) {
304                        config.memory_dir = Some(PathBuf::from(path));
305                    }
306                }
307                "memory" => {
308                    config.memory = Some(parse_memory_block(&block, config.memory.as_ref()));
309                }
310                "storage_url" => {
311                    if let Some(storage_url) = acl_string_attr(&block, &["storage_url"]) {
312                        config.storage_url = Some(storage_url);
313                    }
314                }
315                "skill_dirs" | "skills" => {
316                    if let Some(paths) = acl_path_list_attr(&block, &["skill_dirs", "skills"]) {
317                        config.skill_dirs = paths;
318                    }
319                }
320                "agent_dirs" => {
321                    if let Some(paths) = acl_path_list_attr(&block, &["agent_dirs"]) {
322                        config.agent_dirs = paths;
323                    }
324                }
325                "max_tool_rounds" => {
326                    if let Some(max_tool_rounds) = acl_usize_attr(&block, &["max_tool_rounds"]) {
327                        config.max_tool_rounds = Some(max_tool_rounds);
328                    }
329                }
330                "max_parallel_tasks" => {
331                    if let Some(max_parallel_tasks) =
332                        acl_usize_attr(&block, &["max_parallel_tasks"])
333                    {
334                        config.max_parallel_tasks = Some(max_parallel_tasks);
335                    }
336                }
337                "auto_parallel" | "auto_parallel_enabled" => {
338                    if let Some(auto_parallel) =
339                        acl_bool_attr(&block, &["auto_parallel", "auto_parallel_enabled"])
340                    {
341                        config.auto_parallel = Some(auto_parallel);
342                    }
343                }
344                "auto_delegation" => {
345                    config.auto_delegation =
346                        parse_auto_delegation_block(&block, &config.auto_delegation);
347                }
348                "thinking_budget" => {
349                    if let Some(thinking_budget) = acl_usize_attr(&block, &["thinking_budget"]) {
350                        config.thinking_budget = Some(thinking_budget);
351                    }
352                }
353                "llm_api_timeout_ms" | "api_timeout_ms" | "model_api_timeout_ms" => {
354                    if let Some(timeout_ms) = acl_usize_attr(
355                        &block,
356                        &[
357                            "llm_api_timeout_ms",
358                            "api_timeout_ms",
359                            "model_api_timeout_ms",
360                        ],
361                    ) {
362                        config.llm_api_timeout_ms = Some(timeout_ms as u64);
363                    }
364                }
365                "os" => {
366                    if let Some(address) =
367                        acl_label_or_attr(&block, &["os", "address", "url", "baseUrl", "base_url"])
368                            .map(|value| value.trim().to_string())
369                            .filter(|value| !value.is_empty())
370                    {
371                        config.os = Some(OsConfig { address });
372                    }
373                }
374                "providers" => {
375                    let provider_name = block.labels.first().cloned().ok_or_else(|| {
376                        CodeError::Config(
377                            "providers block requires a label (e.g., providers \"openai\" { ... })"
378                                .into(),
379                        )
380                    })?;
381
382                    let mut provider = ProviderConfig {
383                        name: provider_name.clone(),
384                        api_key: None,
385                        base_url: None,
386                        headers: HashMap::new(),
387                        session_id_header: None,
388                        models: Vec::new(),
389                    };
390
391                    for (key, value) in &block.attributes {
392                        match key.as_str() {
393                            "apiKey" | "api_key" => {
394                                if let Some(api_key) = acl_string(value) {
395                                    provider.api_key = Some(api_key);
396                                }
397                            }
398                            "baseUrl" | "base_url" => {
399                                if let Some(base_url) = acl_string(value) {
400                                    provider.base_url = Some(base_url);
401                                }
402                            }
403                            "sessionIdHeader" | "session_id_header" => {
404                                if let Some(header) = acl_string(value) {
405                                    provider.session_id_header = Some(header);
406                                }
407                            }
408                            _ => {}
409                        }
410                    }
411
412                    // Process nested models blocks
413                    for model_block in &block.blocks {
414                        if model_block.name == "models" {
415                            let model_name =
416                                model_block.labels.first().cloned().ok_or_else(|| {
417                                    CodeError::Config(
418                                        "models block requires a label (e.g., models \"gpt-4\" { ... })"
419                                            .into(),
420                                    )
421                                })?;
422
423                            let mut model = ModelConfig {
424                                id: model_name.clone(),
425                                name: model_name.clone(),
426                                family: String::new(),
427                                api_key: None,
428                                base_url: None,
429                                headers: HashMap::new(),
430                                session_id_header: None,
431                                attachment: false,
432                                reasoning: false,
433                                tool_call: true,
434                                temperature: true,
435                                release_date: None,
436                                modalities: ModelModalities::default(),
437                                cost: ModelCost::default(),
438                                limit: ModelLimit::default(),
439                            };
440
441                            for (key, value) in &model_block.attributes {
442                                match key.as_str() {
443                                    "name" => {
444                                        if let Some(s) = acl_string(value) {
445                                            model.name = s;
446                                        }
447                                    }
448                                    "family" => {
449                                        if let Some(s) = acl_string(value) {
450                                            model.family = s;
451                                        }
452                                    }
453                                    "apiKey" | "api_key" => {
454                                        if let Some(api_key) = acl_string(value) {
455                                            model.api_key = Some(api_key);
456                                        }
457                                    }
458                                    "baseUrl" | "base_url" => {
459                                        if let Some(base_url) = acl_string(value) {
460                                            model.base_url = Some(base_url);
461                                        }
462                                    }
463                                    "sessionIdHeader" | "session_id_header" => {
464                                        if let Some(header) = acl_string(value) {
465                                            model.session_id_header = Some(header);
466                                        }
467                                    }
468                                    "attachment" => {
469                                        model.attachment =
470                                            acl_bool_attr(model_block, &["attachment"])
471                                                .unwrap_or(model.attachment);
472                                    }
473                                    "reasoning" => {
474                                        model.reasoning =
475                                            acl_bool_attr(model_block, &["reasoning"])
476                                                .unwrap_or(model.reasoning);
477                                    }
478                                    "toolCall" | "tool_call" => {
479                                        model.tool_call =
480                                            acl_bool_attr(model_block, &["toolCall", "tool_call"])
481                                                .unwrap_or(model.tool_call);
482                                    }
483                                    "temperature" => {
484                                        model.temperature =
485                                            acl_bool_attr(model_block, &["temperature"])
486                                                .unwrap_or(model.temperature);
487                                    }
488                                    "releaseDate" | "release_date" => {
489                                        if let Some(release_date) = acl_string(value) {
490                                            model.release_date = Some(release_date);
491                                        }
492                                    }
493                                    "maxTokens" => {
494                                        tracing::warn!(
495                                            provider = %provider.name,
496                                            model = %model.id,
497                                            field = "maxTokens",
498                                            "Flat ACL model token limit fields are deprecated; use limit = {{ output = ..., context = ... }}"
499                                        );
500                                        if let Some(output) = acl_u32(value) {
501                                            model.limit.output = output;
502                                        }
503                                    }
504                                    "contextTokens" => {
505                                        tracing::warn!(
506                                            provider = %provider.name,
507                                            model = %model.id,
508                                            field = "contextTokens",
509                                            "Flat ACL model token limit fields are deprecated; use limit = {{ output = ..., context = ... }}"
510                                        );
511                                        if let Some(context) = acl_u32(value) {
512                                            model.limit.context = context;
513                                        }
514                                    }
515                                    "limit" => {
516                                        if let Some(output) =
517                                            acl_object_u32_attr(value, &["output"])
518                                        {
519                                            model.limit.output = output;
520                                        }
521                                        if let Some(context) =
522                                            acl_object_u32_attr(value, &["context"])
523                                        {
524                                            model.limit.context = context;
525                                        }
526                                    }
527                                    _ => {}
528                                }
529                            }
530
531                            provider.models.push(model);
532                        }
533                    }
534
535                    config.providers.push(provider);
536                }
537                _ => {
538                    // Other top-level blocks are not mapped by the lightweight
539                    // ACL loader yet (queue, search, memory, MCP, etc.).
540                }
541            }
542        }
543
544        if let Some(auto_parallel) = config.auto_parallel {
545            config.auto_delegation.auto_parallel = auto_parallel;
546        }
547
548        Ok(config)
549    }
550
551    /// Find a provider by name
552    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
553        self.providers.iter().find(|p| p.name == name)
554    }
555
556    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
557    pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
558        let default = self.default_model.as_ref()?;
559        let (provider_name, _) = default.split_once('/')?;
560        self.find_provider(provider_name)
561    }
562
563    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
564    pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
565        let default = self.default_model.as_ref()?;
566        let (provider_name, model_id) = default.split_once('/')?;
567        let provider = self.find_provider(provider_name)?;
568        let model = provider.find_model(model_id)?;
569        Some((provider, model))
570    }
571
572    /// Get LlmConfig for the default provider and model
573    ///
574    /// Returns None if default provider/model is not configured or API key is missing.
575    pub fn default_llm_config(&self) -> Option<LlmConfig> {
576        let (provider, model) = self.default_model_config()?;
577        let api_key = provider.get_api_key(model)?;
578        let base_url = provider.get_base_url(model);
579        let headers = provider.get_headers(model);
580        let session_id_header = provider.get_session_id_header(model);
581
582        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
583        if let Some(url) = base_url {
584            config = config.with_base_url(url);
585        }
586        if !headers.is_empty() {
587            config = config.with_headers(headers);
588        }
589        if let Some(header_name) = session_id_header {
590            config = config.with_session_id_header(header_name);
591        }
592        if let Some(timeout_ms) = self.llm_api_timeout_ms {
593            config = config.with_api_timeout(timeout_ms);
594        }
595        config = apply_model_caps(config, model, self.thinking_budget);
596        Some(config)
597    }
598
599    /// Get LlmConfig for a specific provider and model
600    ///
601    /// Returns None if provider/model is not found or API key is missing.
602    pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
603        let provider = self.find_provider(provider_name)?;
604        let model = provider.find_model(model_id)?;
605        let api_key = provider.get_api_key(model)?;
606        let base_url = provider.get_base_url(model);
607        let headers = provider.get_headers(model);
608        let session_id_header = provider.get_session_id_header(model);
609
610        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
611        if let Some(url) = base_url {
612            config = config.with_base_url(url);
613        }
614        if !headers.is_empty() {
615            config = config.with_headers(headers);
616        }
617        if let Some(header_name) = session_id_header {
618            config = config.with_session_id_header(header_name);
619        }
620        if let Some(timeout_ms) = self.llm_api_timeout_ms {
621            config = config.with_api_timeout(timeout_ms);
622        }
623        config = apply_model_caps(config, model, self.thinking_budget);
624        Some(config)
625    }
626
627    /// List all available models across all providers
628    pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
629        self.providers
630            .iter()
631            .flat_map(|p| p.models.iter().map(move |m| (p, m)))
632            .collect()
633    }
634
635    /// Add a skill directory
636    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
637        self.skill_dirs.push(dir.into());
638        self
639    }
640
641    /// Add an agent directory
642    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
643        self.agent_dirs.push(dir.into());
644        self
645    }
646
647    /// Check if any directories are configured
648    pub fn has_directories(&self) -> bool {
649        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
650    }
651
652    /// Check if provider configuration is available
653    pub fn has_providers(&self) -> bool {
654        !self.providers.is_empty()
655    }
656}