a431d1ec95
Add OpenAI-compatible providers, TUI, slash commands, and model switching. Add local file tools plus optional yolo shell execution. Document config, usage, and development workflow.
224 lines
5.6 KiB
Go
224 lines
5.6 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const DefaultPath = "~/.agentu/config.yaml"
|
|
|
|
type Config struct {
|
|
Providers map[string]ProviderConfig `yaml:"providers"`
|
|
Agent AgentConfig `yaml:"agent"`
|
|
}
|
|
|
|
type ProviderConfig struct {
|
|
Name string
|
|
BaseURL string `yaml:"base_url"`
|
|
Model string `yaml:"model"`
|
|
Models []string `yaml:"models"`
|
|
APIKey string `yaml:"api_key"`
|
|
Thinking string `yaml:"thinking"`
|
|
ThinkingParam string `yaml:"thinking_param"`
|
|
ThinkingConfigured bool
|
|
}
|
|
|
|
type AgentConfig struct {
|
|
SystemPrompt string `yaml:"system_prompt"`
|
|
WorkingDir string `yaml:"working_dir"`
|
|
ToolTimeout time.Duration `yaml:"tool_timeout"`
|
|
}
|
|
|
|
type rawConfig struct {
|
|
Providers map[string]rawProviderConfig `yaml:"providers"`
|
|
Agent struct {
|
|
SystemPrompt string `yaml:"system_prompt"`
|
|
WorkingDir string `yaml:"working_dir"`
|
|
ToolTimeout string `yaml:"tool_timeout"`
|
|
} `yaml:"agent"`
|
|
}
|
|
|
|
type rawProviderConfig struct {
|
|
BaseURL string `yaml:"base_url"`
|
|
Model string `yaml:"model"`
|
|
Models []string `yaml:"models"`
|
|
APIKey string `yaml:"api_key"`
|
|
Thinking *string `yaml:"thinking"`
|
|
ThinkingParam string `yaml:"thinking_param"`
|
|
}
|
|
|
|
func Load(path string) (*Config, error) {
|
|
path, err := ResolvePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
|
|
expanded := os.ExpandEnv(string(data))
|
|
var raw rawConfig
|
|
decoder := yaml.NewDecoder(strings.NewReader(expanded))
|
|
decoder.KnownFields(true)
|
|
if err := decoder.Decode(&raw); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Providers: normalizeProviders(raw),
|
|
Agent: AgentConfig{
|
|
SystemPrompt: raw.Agent.SystemPrompt,
|
|
WorkingDir: raw.Agent.WorkingDir,
|
|
},
|
|
}
|
|
|
|
if cfg.Agent.WorkingDir == "" {
|
|
cfg.Agent.WorkingDir = "."
|
|
}
|
|
absWorkingDir, err := filepath.Abs(cfg.Agent.WorkingDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve working_dir: %w", err)
|
|
}
|
|
cfg.Agent.WorkingDir = absWorkingDir
|
|
|
|
if raw.Agent.ToolTimeout == "" {
|
|
cfg.Agent.ToolTimeout = 30 * time.Second
|
|
} else {
|
|
timeout, err := time.ParseDuration(raw.Agent.ToolTimeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse agent.tool_timeout: %w", err)
|
|
}
|
|
cfg.Agent.ToolTimeout = timeout
|
|
}
|
|
|
|
if cfg.Agent.SystemPrompt == "" {
|
|
cfg.Agent.SystemPrompt = "You are agentu, a concise local agent. Answer simple conversation directly. Use tools only when the user's request needs local project context or local command execution."
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func ResolvePath(path string) (string, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
path = DefaultPath
|
|
}
|
|
if path == "~" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
|
}
|
|
return home, nil
|
|
}
|
|
if strings.HasPrefix(path, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
|
}
|
|
return filepath.Join(home, strings.TrimPrefix(path, "~/")), nil
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func (c *Config) Validate() error {
|
|
if len(c.Providers) == 0 {
|
|
return errors.New("missing required config: providers")
|
|
}
|
|
for name, provider := range c.Providers {
|
|
var missing []string
|
|
prefix := "providers." + name
|
|
if strings.TrimSpace(provider.BaseURL) == "" {
|
|
missing = append(missing, prefix+".base_url")
|
|
}
|
|
if strings.TrimSpace(provider.Model) == "" {
|
|
missing = append(missing, prefix+".model")
|
|
}
|
|
if strings.TrimSpace(provider.APIKey) == "" {
|
|
missing = append(missing, prefix+".api_key")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
|
|
}
|
|
if provider.ThinkingConfigured && !IsThinkingLevel(provider.Thinking) {
|
|
return fmt.Errorf("%s.thinking must be one of: %s", prefix, strings.Join(ThinkingLevels(), ", "))
|
|
}
|
|
}
|
|
if c.Agent.ToolTimeout <= 0 {
|
|
return errors.New("agent.tool_timeout must be greater than zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) DefaultProviderName() string {
|
|
names := c.ProviderNames()
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
return names[0]
|
|
}
|
|
|
|
func (c *Config) ProviderNames() []string {
|
|
names := make([]string, 0, len(c.Providers))
|
|
for name := range c.Providers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func normalizeProviders(raw rawConfig) map[string]ProviderConfig {
|
|
providers := make(map[string]ProviderConfig)
|
|
for name, provider := range raw.Providers {
|
|
providers[name] = normalizeProvider(name, provider)
|
|
}
|
|
return providers
|
|
}
|
|
|
|
func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig {
|
|
provider := ProviderConfig{
|
|
Name: name,
|
|
BaseURL: raw.BaseURL,
|
|
Model: raw.Model,
|
|
Models: append([]string(nil), raw.Models...),
|
|
APIKey: raw.APIKey,
|
|
ThinkingParam: raw.ThinkingParam,
|
|
}
|
|
if provider.Model == "" && len(provider.Models) > 0 {
|
|
provider.Model = provider.Models[0]
|
|
}
|
|
if raw.Thinking != nil {
|
|
provider.Thinking = strings.ToLower(strings.TrimSpace(*raw.Thinking))
|
|
provider.ThinkingConfigured = true
|
|
if provider.ThinkingParam == "" {
|
|
provider.ThinkingParam = "thinking"
|
|
}
|
|
}
|
|
return provider
|
|
}
|
|
|
|
func ThinkingLevels() []string {
|
|
return []string{"none", "middle", "high", "xhigh", "max"}
|
|
}
|
|
|
|
func IsThinkingLevel(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
for _, level := range ThinkingLevels() {
|
|
if value == level {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|