diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index c3ddbb77f..0af743bb5 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -23,16 +23,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error { sessionKey = "cli:default" } - if debug { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") - } - cfg, err := internal.LoadConfig() if err != nil { return fmt.Errorf("error loading config: %w", err) } + if debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("🔍 Debug mode enabled") + } + if model != "" { cfg.Agents.Defaults.ModelName = model } diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index 6b2d65c91..ae1d58c29 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) const Logo = "🦞" @@ -27,7 +28,12 @@ func GetConfigPath() string { } func LoadConfig() (*config.Config, error) { - return config.LoadConfig(GetConfigPath()) + cfg, err := config.LoadConfig(GetConfigPath()) + if err != nil { + return nil, err + } + logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel) + return cfg, nil } // FormatVersion returns the version string with optional git commit diff --git a/config/config.example.json b/config/config.example.json index 81c9014ec..69e8feeae 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,6 +1,7 @@ { "agents": { "defaults": { + "log_level": "fatal", "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, "model_name": "gpt-5.4", diff --git a/pkg/config/config.go b/pkg/config/config.go index 235cb0641..ddafc409d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -245,6 +245,7 @@ type AgentDefaults struct { MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` } const ( diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 588c04645..45906ee70 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -470,6 +470,13 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { } } +func TestDefaultConfig_LogLevel(t *testing.T) { + cfg := DefaultConfig() + if cfg.Agents.Defaults.LogLevel != "fatal" { + t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel) + } +} + func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -1057,3 +1064,38 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) } } + +func TestConfigParsesLogLevel(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"agents":{"defaults":{"log_level":"debug"}}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Agents.Defaults.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want \"debug\"", cfg.Agents.Defaults.LogLevel) + } +} + +func TestConfigLogLevelEmpty(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // When config omits log_level, the DefaultConfig value ("fatal") is preserved. + if cfg.Agents.Defaults.LogLevel != "fatal" { + t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 0d2141ae1..cec333888 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -26,6 +26,7 @@ func DefaultConfig() *Config { return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ + LogLevel: "fatal", Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 9a2706b3b..4ad4e950e 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -79,16 +79,18 @@ func (p *startupBlockedProvider) GetDefaultModel() string { // Run starts the gateway runtime using the configuration loaded from configPath. func Run(debug bool, configPath string, allowEmptyStartup bool) error { - if debug { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") - } - cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("error loading config: %w", err) } + logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel) + + if debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("🔍 Debug mode enabled") + } + provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) if err != nil { return fmt.Errorf("error creating provider: %w", err) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index c5a1f895a..179804607 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -106,6 +106,36 @@ func GetLevel() LogLevel { return currentLevel } +// ParseLevel converts a case-insensitive level name to a LogLevel. +// Returns the level and true if valid, or (INFO, false) if unrecognized. +func ParseLevel(s string) (LogLevel, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return DEBUG, true + case "info": + return INFO, true + case "warn", "warning": + return WARN, true + case "error": + return ERROR, true + case "fatal": + return FATAL, true + default: + return INFO, false + } +} + +// SetLevelFromString sets the log level from a string value. +// If the string is empty or not a recognized level name, the current level is kept. +func SetLevelFromString(s string) { + if s == "" { + return + } + if level, ok := ParseLevel(s); ok { + SetLevel(level) + } +} + func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 31b40484c..e551db58e 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -252,3 +252,88 @@ func TestFormatFieldValue(t *testing.T) { }) } } + +func TestDefaultLevelIsInfo(t *testing.T) { + // The package-level default (before any SetLevel call) should be INFO. + // Because earlier tests may have changed it, we just verify the constant is wired correctly. + if logLevelNames[INFO] != "INFO" { + t.Errorf("INFO constant mapped to %q, want \"INFO\"", logLevelNames[INFO]) + } +} + +func TestParseLevelValid(t *testing.T) { + tests := []struct { + input string + want LogLevel + }{ + {"debug", DEBUG}, + {"DEBUG", DEBUG}, + {"Debug", DEBUG}, + {"info", INFO}, + {"INFO", INFO}, + {"warn", WARN}, + {"WARN", WARN}, + {"warning", WARN}, + {"WARNING", WARN}, + {"error", ERROR}, + {"ERROR", ERROR}, + {"fatal", FATAL}, + {"FATAL", FATAL}, + {" info ", INFO}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseLevel(tt.input) + if !ok { + t.Fatalf("ParseLevel(%q) returned ok=false, want true", tt.input) + } + if got != tt.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseLevelInvalid(t *testing.T) { + tests := []string{"", "garbage", "verbose", "trace", "critical"} + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + _, ok := ParseLevel(input) + if ok { + t.Errorf("ParseLevel(%q) returned ok=true, want false", input) + } + }) + } +} + +func TestSetLevelFromString(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + + // Valid string changes the level + SetLevel(INFO) + SetLevelFromString("error") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"error\"): GetLevel() = %v, want ERROR", got) + } + + // Empty string is a no-op + SetLevelFromString("") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"\"): GetLevel() = %v, want ERROR (unchanged)", got) + } + + // Invalid string is a no-op + SetLevelFromString("garbage") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"garbage\"): GetLevel() = %v, want ERROR (unchanged)", got) + } + + // Case-insensitive + SetLevelFromString("FATAL") + if got := GetLevel(); got != FATAL { + t.Errorf("after SetLevelFromString(\"FATAL\"): GetLevel() = %v, want FATAL", got) + } +}