// This file implements the layered configuration system (US-023, #42), the // pigo port of pi/zero's config resolution. A resolved Config is produced by // merging partial layers in precedence order: // // default < global < project < environment/CLI // // Each layer is a *ConfigLayer whose fields are pointers, so "unset" (nil) is // distinguishable from "set to the zero value" — only set fields override lower // layers (field-level replacement, no deep merge). The final Config is // validated: an unknown thinking level or tool-execution mode is a hard error, // as is a malformed layer file. package runtime import ( "encoding/json" "fmt" "os" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/hooks" ) // Config is the fully resolved configuration a run operates under, after all // layers are merged and validated. type Config struct { // Model is the default model id used when a run does not specify one. Model string // Provider is the default provider name. Provider string // Credentials maps provider name → API key. Merged per-provider across // layers (a higher layer's key for provider X overrides a lower one, but // providers only present in a lower layer are retained). Credentials map[string]string // ToolExecutionMode is the default execution mode for tools that do not pin // their own mode. ToolExecutionMode agentcore.ToolExecutionMode // ThinkingLevel is the default reasoning-effort level. ThinkingLevel agentcore.ThinkingLevel // Hooks are the resolved, append-merged hook matchers keyed by event type. // Nil when no layer defined any hooks (FR-18), so the no-hooks path costs // nothing downstream. Hooks hooks.HookSet } // ConfigLayer is one partial layer of configuration. Pointer/optional fields // distinguish "not set in this layer" (nil/empty) from an explicit value, so a // higher layer only overrides the fields it actually sets. type ConfigLayer struct { Model *string `json:"model,omitempty"` Provider *string `json:"provider,omitempty"` Credentials map[string]string `json:"credentials,omitempty"` ToolExecutionMode *string `json:"toolExecutionMode,omitempty"` ThinkingLevel *string `json:"thinkingLevel,omitempty"` // Hooks are this layer's hook matchers keyed by event type. Unlike the // scalar fields, hooks are not overridden across layers: ResolveConfig // appends each layer's matchers per event type (FR-2), so lower-layer hooks // always still fire. Hooks hooks.HookSet `json:"hooks,omitempty"` } // DefaultConfigLayer is the base layer applied before all others. It gives a // usable configuration out of the box. func DefaultConfigLayer() ConfigLayer { model := "openrouter/free" provider := "openrouter" mode := string(agentcore.ToolExecutionParallel) level := string(agentcore.ThinkingMedium) return ConfigLayer{ Model: &model, Provider: &provider, ToolExecutionMode: &mode, ThinkingLevel: &level, } } // LoadConfigLayer reads and decodes a single JSON config layer from path. A // missing file yields a nil layer and no error (an absent layer is not a // failure); a present-but-malformed file is a hard error. func LoadConfigLayer(path string) (*ConfigLayer, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read config %s: %w", path, err) } var layer ConfigLayer if err := json.Unmarshal(data, &layer); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } return &layer, nil } // ResolveConfig merges the given layers in ascending precedence order (earlier // layers are overridden by later ones) and validates the result. Nil layers are // skipped, so callers can pass the output of LoadConfigLayer directly. The // merge is field-level: a later layer only overrides fields it sets, except // Credentials, which merges per-provider. func ResolveConfig(layers ...*ConfigLayer) (Config, error) { var cfg Config for _, layer := range layers { if layer == nil { continue } if layer.Model != nil { cfg.Model = *layer.Model } if layer.Provider != nil { cfg.Provider = *layer.Provider } if layer.ToolExecutionMode != nil { cfg.ToolExecutionMode = agentcore.ToolExecutionMode(*layer.ToolExecutionMode) } if layer.ThinkingLevel != nil { cfg.ThinkingLevel = agentcore.ThinkingLevel(*layer.ThinkingLevel) } for provider, key := range layer.Credentials { if cfg.Credentials == nil { cfg.Credentials = make(map[string]string) } cfg.Credentials[provider] = key } for eventType, matchers := range layer.Hooks { if len(matchers) == 0 { continue } if cfg.Hooks == nil { cfg.Hooks = make(hooks.HookSet) } cfg.Hooks[eventType] = append(cfg.Hooks[eventType], matchers...) } } if err := cfg.validate(); err != nil { return Config{}, err } return cfg, nil } // EnvConfigLayer builds a config layer from environment variables, the highest // file-independent layer (below only explicit CLI flags). Recognized: // // PIGO_MODEL, PIGO_PROVIDER, PIGO_TOOL_EXECUTION_MODE, PIGO_THINKING_LEVEL // // Only set variables contribute; unset ones leave the field nil so lower layers // show through. Credential env vars are intentionally NOT captured here — keys // are resolved lazily by the CredentialStore and never merged into a struct // that might be logged (US-012). func EnvConfigLayer(getenv func(string) string) ConfigLayer { if getenv == nil { getenv = os.Getenv } var layer ConfigLayer if v := getenv("PIGO_MODEL"); v != "" { layer.Model = &v } if v := getenv("PIGO_PROVIDER"); v != "" { layer.Provider = &v } if v := getenv("PIGO_TOOL_EXECUTION_MODE"); v != "" { layer.ToolExecutionMode = &v } if v := getenv("PIGO_THINKING_LEVEL"); v != "" { layer.ThinkingLevel = &v } return layer } // validate reports the first invalid field in the resolved config: an unknown // tool-execution mode or thinking level. An empty model is also rejected, since // a run cannot proceed without one. func (c Config) validate() error { if c.Model == "" { return fmt.Errorf("config: model must not be empty") } switch c.ToolExecutionMode { case agentcore.ToolExecutionParallel, agentcore.ToolExecutionSequential: default: return fmt.Errorf("config: invalid toolExecutionMode %q (want parallel|sequential)", c.ToolExecutionMode) } switch c.ThinkingLevel { case agentcore.ThinkingOff, agentcore.ThinkingMinimal, agentcore.ThinkingLow, agentcore.ThinkingMedium, agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax: default: return fmt.Errorf("config: invalid thinkingLevel %q", c.ThinkingLevel) } return nil }