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