feat(config): add YAML config loader with hierarchical precedence

This commit is contained in:
Victor Hugo Vargas Servin 2026-06-30 23:53:20 -07:00
parent 2509029777
commit ce64b68f12
3 changed files with 257 additions and 0 deletions

70
pkg/config/config.go Normal file
View file

@ -0,0 +1,70 @@
package config
import (
"context"
"fmt"
)
// ProviderConfig holds provider-specific settings.
type ProviderConfig struct {
Type string `yaml:"type"`
Model string `yaml:"model"`
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url,omitempty"`
MaxTokens int `yaml:"max_tokens,omitempty"`
Temperature float32 `yaml:"temperature,omitempty"`
}
// ToolPolicy controls which tools are available and their permissions.
type ToolPolicy struct {
DefaultPermission string `yaml:"default_permission"`
AllowList []string `yaml:"allow_list,omitempty"`
DenyList []string `yaml:"deny_list,omitempty"`
}
// LoggingConfig controls logging output.
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
}
// Config is the top-level configuration for the agent.
type Config struct {
Model string `yaml:"model"`
Provider ProviderConfig `yaml:"provider"`
Tools ToolPolicy `yaml:"tools"`
Logging LoggingConfig `yaml:"logging"`
}
// Loader is responsible for loading configuration from various sources.
type Loader interface {
Load(ctx context.Context, workdir string) (Config, error)
}
// ErrConfigNotFound is returned when no configuration file is found.
var ErrConfigNotFound = fmt.Errorf("config file not found")
// ErrInvalidConfig is returned when the configuration is invalid.
var ErrInvalidConfig = fmt.Errorf("invalid configuration")
// LoadDefault returns a Config with sensible defaults.
func LoadDefault() Config {
return Config{
Model: "gpt-4o",
Provider: ProviderConfig{
Type: "openai",
Model: "gpt-4o",
Temperature: 0.7,
MaxTokens: 4096,
},
Tools: ToolPolicy{
DefaultPermission: "allow",
},
Logging: LoggingConfig{
Level: "info",
Format: "text",
Output: "stderr",
},
}
}

82
pkg/config/config_test.go Normal file
View file

@ -0,0 +1,82 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestLoadDefault(t *testing.T) {
cfg := LoadDefault()
if cfg.Model != "gpt-4o" {
t.Errorf("expected default model 'gpt-4o', got %q", cfg.Model)
}
if cfg.Provider.Type != "openai" {
t.Errorf("expected default provider 'openai', got %q", cfg.Provider.Type)
}
}
func TestYAMLLoader_NoFile(t *testing.T) {
loader := NewYAMLLoader()
ctx := context.Background()
cfg, err := loader.Load(ctx, "/tmp/nonexistent_workdir_12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Model != "gpt-4o" {
t.Errorf("expected default model, got %q", cfg.Model)
}
}
func TestYAMLLoader_WithFile(t *testing.T) {
tmpDir := t.TempDir()
yaml := `
provider:
type: anthropic
model: claude-sonnet-4.5
api_key: test-key
`
if err := os.WriteFile(filepath.Join(tmpDir, "rony.yaml"), []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
loader := NewYAMLLoader()
ctx := context.Background()
cfg, err := loader.Load(ctx, tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Provider.Type != "anthropic" {
t.Errorf("expected provider 'anthropic', got %q", cfg.Provider.Type)
}
if cfg.Provider.Model != "claude-sonnet-4.5" {
t.Errorf("expected model 'claude-sonnet-4.5', got %q", cfg.Provider.Model)
}
if cfg.Provider.APIKey != "test-key" {
t.Errorf("expected api_key 'test-key', got %q", cfg.Provider.APIKey)
}
}
func TestYAMLLoader_PreservesDefaults(t *testing.T) {
tmpDir := t.TempDir()
yaml := `
provider:
type: openai
model: gpt-4o-mini
`
if err := os.WriteFile(filepath.Join(tmpDir, "rony.yaml"), []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
loader := NewYAMLLoader()
ctx := context.Background()
cfg, err := loader.Load(ctx, tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should still have default logging config
if cfg.Logging.Level != "info" {
t.Errorf("expected default logging level 'info', got %q", cfg.Logging.Level)
}
}

105
pkg/config/loader.go Normal file
View file

@ -0,0 +1,105 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// yamlLoader loads config from YAML files with hierarchical precedence.
type yamlLoader struct{}
// NewYAMLLoader returns a Loader that reads from YAML files.
func NewYAMLLoader() Loader {
return &yamlLoader{}
}
func (l *yamlLoader) Load(ctx context.Context, workdir string) (Config, error) {
defaults := LoadDefault()
// Load from workdir
path := filepath.Join(workdir, "rony.yaml")
if _, err := os.Stat(path); err == nil {
cfg, err := loadFromFile(path)
if err != nil {
return Config{}, fmt.Errorf("loading %s: %w", path, err)
}
// Merge with defaults
cfg = merge(defaults, cfg)
return cfg, nil
}
// Load from home directory
home, err := os.UserHomeDir()
if err == nil {
homePath := filepath.Join(home, ".config", "rony", "config.yaml")
if _, err := os.Stat(homePath); err == nil {
cfg, err := loadFromFile(homePath)
if err == nil {
return merge(defaults, cfg), nil
}
}
}
return defaults, nil
}
func loadFromFile(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing YAML: %w", err)
}
return cfg, nil
}
// merge combines two configs, with b taking precedence over a.
func merge(a, b Config) Config {
if b.Model != "" {
a.Model = b.Model
}
if b.Provider.Type != "" {
a.Provider.Type = b.Provider.Type
}
if b.Provider.Model != "" {
a.Provider.Model = b.Provider.Model
}
if b.Provider.APIKey != "" {
a.Provider.APIKey = b.Provider.APIKey
}
if b.Provider.BaseURL != "" {
a.Provider.BaseURL = b.Provider.BaseURL
}
if b.Provider.MaxTokens > 0 {
a.Provider.MaxTokens = b.Provider.MaxTokens
}
if b.Provider.Temperature > 0 {
a.Provider.Temperature = b.Provider.Temperature
}
if b.Tools.DefaultPermission != "" {
a.Tools.DefaultPermission = b.Tools.DefaultPermission
}
if len(b.Tools.AllowList) > 0 {
a.Tools.AllowList = b.Tools.AllowList
}
if len(b.Tools.DenyList) > 0 {
a.Tools.DenyList = b.Tools.DenyList
}
if b.Logging.Level != "" {
a.Logging.Level = b.Logging.Level
}
if b.Logging.Format != "" {
a.Logging.Format = b.Logging.Format
}
if b.Logging.Output != "" {
a.Logging.Output = b.Logging.Output
}
return a
}