feat(config): add YAML config loader with hierarchical precedence
This commit is contained in:
parent
2509029777
commit
ce64b68f12
3 changed files with 257 additions and 0 deletions
70
pkg/config/config.go
Normal file
70
pkg/config/config.go
Normal 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
82
pkg/config/config_test.go
Normal 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
105
pkg/config/loader.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue