187 lines
6.6 KiB
Go
187 lines
6.6 KiB
Go
// 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
|
|
}
|