v0.0.1: agentu mvp
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.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadExpandsEnvAndDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
agent:
|
||||
working_dir: .
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := cfg.Providers[cfg.DefaultProviderName()]
|
||||
if provider.APIKey != "sk-test" {
|
||||
t.Fatalf("api key = %q", provider.APIKey)
|
||||
}
|
||||
if providerName := cfg.DefaultProviderName(); providerName != "test" {
|
||||
t.Fatalf("default provider = %q", providerName)
|
||||
}
|
||||
if cfg.Agent.ToolTimeout != 30*time.Second {
|
||||
t.Fatalf("tool timeout = %s", cfg.Agent.ToolTimeout)
|
||||
}
|
||||
if !filepath.IsAbs(cfg.Agent.WorkingDir) {
|
||||
t.Fatalf("working dir is not absolute: %s", cfg.Agent.WorkingDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesDefaultConfigPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
configDir := filepath.Join(dir, ".agentu")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(configDir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.DefaultProviderName(); got != "test" {
|
||||
t.Fatalf("default provider = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathExpandsHome(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
|
||||
got, err := ResolvePath("~/.agentu/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dir, ".agentu", "config.yaml")
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSystemPromptDiscouragesEagerTools(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Answer simple conversation directly", "Use tools only when"} {
|
||||
if !strings.Contains(cfg.Agent.SystemPrompt, want) {
|
||||
t.Fatalf("system prompt missing %q: %s", want, cfg.Agent.SystemPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMultiProviderConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
loveuer:
|
||||
base_url: https://example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
model: model-a
|
||||
models:
|
||||
- model-a
|
||||
- model-b
|
||||
thinking: xhigh
|
||||
thinking_param: thinking
|
||||
backup:
|
||||
base_url: https://backup.example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- model-c
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.DefaultProviderName(); got != "backup" {
|
||||
t.Fatalf("default provider = %q", got)
|
||||
}
|
||||
provider := cfg.Providers["loveuer"]
|
||||
if got := provider.Model; got != "model-a" {
|
||||
t.Fatalf("active model = %q", got)
|
||||
}
|
||||
if got := cfg.Providers["backup"].Model; got != "model-c" {
|
||||
t.Fatalf("backup default model = %q", got)
|
||||
}
|
||||
if !provider.ThinkingConfigured || provider.Thinking != "xhigh" || provider.ThinkingParam != "thinking" {
|
||||
t.Fatalf("thinking config = %#v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultProviderNameIsDeterministic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
two:
|
||||
base_url: https://two.example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
model: model-b
|
||||
one:
|
||||
base_url: https://one.example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
model: model-a
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.DefaultProviderName(); got != "one" {
|
||||
t.Fatalf("default provider = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidThinking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
thinking: huge
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "thinking") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresProviderFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyProviderConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
provider:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: sk-test
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "field provider not found") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsActiveProviderConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
active_provider: loveuer
|
||||
providers:
|
||||
loveuer:
|
||||
base_url: https://example.com
|
||||
model: test-model
|
||||
api_key: sk-test
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "field active_provider not found") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user