Merge pull request #1853 from kunalk16/feat-configurable-logger

feat(logging): add configurability for log levels preference
This commit is contained in:
daming大铭
2026-03-22 13:27:06 +08:00
committed by GitHub
9 changed files with 179 additions and 11 deletions
+5 -5
View File
@@ -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
}
+7 -1
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
{
"agents": {
"defaults": {
"log_level": "fatal",
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt-5.4",
+1
View File
@@ -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 (
+42
View File
@@ -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)
}
}
+1
View File
@@ -26,6 +26,7 @@ func DefaultConfig() *Config {
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
LogLevel: "fatal",
Workspace: workspacePath,
RestrictToWorkspace: true,
Provider: "",
+7 -5
View File
@@ -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)
+30
View File
@@ -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()
+85
View File
@@ -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)
}
}