From 568bdcb0cd93c776d74702d26eab372353d643da Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:09:32 +0900 Subject: [PATCH] feat: add logs tool for on-demand log analysis Add a `logs` tool that reads from the in-memory ring buffer with level/component/limit/query filters. Defaults to WARN level to minimize token consumption when the AI provider analyzes logs. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/instance.go | 1 + pkg/tools/logs.go | 99 ++++++++++++++++ pkg/tools/logs_test.go | 262 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 pkg/tools/logs.go create mode 100644 pkg/tools/logs_test.go diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 11a9d55dc..b1c80dded 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -61,6 +61,7 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewBgMonitorTool(execTool)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewLogsTool()) sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) diff --git a/pkg/tools/logs.go b/pkg/tools/logs.go new file mode 100644 index 000000000..00fea3395 --- /dev/null +++ b/pkg/tools/logs.go @@ -0,0 +1,99 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// LogsTool provides on-demand access to application logs from the in-memory ring buffer. +// Designed for token-efficient log analysis: defaults to WARN level to exclude noise. +type LogsTool struct{} + +func NewLogsTool() *LogsTool { + return &LogsTool{} +} + +func (t *LogsTool) Name() string { return "logs" } + +func (t *LogsTool) Description() string { + return "Retrieve recent application logs from the in-memory ring buffer. " + + "Use level filter to minimize token usage (default: WARN). " + + "Call this when the user asks about errors, issues, or system health." +} + +func (t *LogsTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "level": map[string]any{ + "type": "string", + "description": "Minimum log level: DEBUG, INFO, WARN, ERROR. Default: WARN", + "enum": []string{"DEBUG", "INFO", "WARN", "ERROR"}, + }, + "component": map[string]any{ + "type": "string", + "description": "Filter by component name (e.g. telegram, discord, slack, agent)", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of log entries to return. Default: 50", + }, + "query": map[string]any{ + "type": "string", + "description": "Filter by substring match in log message", + }, + }, + } +} + +func (t *LogsTool) Execute(_ context.Context, args map[string]any) *ToolResult { + // Parse level (default: WARN) + level := logger.WARN + if lvlStr, ok := args["level"].(string); ok && lvlStr != "" { + level = logger.ParseLevel(lvlStr) + } + + // Parse component + component, _ := args["component"].(string) + + // Parse limit (default: 50, max: 300) + limit := 50 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + if limit > 300 { + limit = 300 + } + + // Parse query + query, _ := args["query"].(string) + + // Fetch from ring buffer (already sanitized by RecentLogs) + entries := logger.RecentLogs(level, component, limit) + + // Apply query filter if specified + if query != "" { + filtered := make([]logger.LogEntry, 0, len(entries)) + queryLower := strings.ToLower(query) + for _, e := range entries { + if strings.Contains(strings.ToLower(e.Message), queryLower) { + filtered = append(filtered, e) + } + } + entries = filtered + } + + if len(entries) == 0 { + return SilentResult("No log entries found matching the criteria.") + } + + data, err := json.Marshal(entries) + if err != nil { + return ErrorResult("Failed to marshal log entries: " + err.Error()) + } + + return SilentResult(string(data)) +} diff --git a/pkg/tools/logs_test.go b/pkg/tools/logs_test.go new file mode 100644 index 000000000..aa63ba913 --- /dev/null +++ b/pkg/tools/logs_test.go @@ -0,0 +1,262 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func setupTestLogs(t *testing.T) { + t.Helper() + prev := logger.GetLevel() + t.Cleanup(func() { logger.SetLevel(prev) }) + logger.SetLevel(logger.DEBUG) + + logger.DebugC("agent", "debug message") + logger.InfoC("telegram", "message received") + logger.WarnC("telegram", "webhook retry") + logger.ErrorC("discord", "connection timeout") + logger.WarnCF("wecom", "signature failed", map[string]any{ + "token": "secret-value", + "nonce": "safe-value", + }) +} + +func TestLogsTool_DefaultLevel(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + for _, e := range entries { + if e.Level == "DEBUG" || e.Level == "INFO" { + t.Errorf("default level should be WARN, but got %s entry: %s", e.Level, e.Message) + } + } +} + +func TestLogsTool_LevelFilter(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "ERROR", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + for _, e := range entries { + if e.Level != "ERROR" && e.Level != "FATAL" { + t.Errorf("expected only ERROR+, got %s: %s", e.Level, e.Message) + } + } +} + +func TestLogsTool_ComponentFilter(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "DEBUG", + "component": "telegram", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + for _, e := range entries { + if e.Component != "telegram" { + t.Errorf("expected component=telegram, got %s", e.Component) + } + } +} + +func TestLogsTool_QueryFilter(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "DEBUG", + "query": "timeout", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if len(entries) == 0 { + t.Fatal("expected at least one entry matching 'timeout'") + } + for _, e := range entries { + if !strings.Contains(strings.ToLower(e.Message), "timeout") { + t.Errorf("entry should contain 'timeout': %s", e.Message) + } + } +} + +func TestLogsTool_QueryCaseInsensitive(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "DEBUG", + "query": "TIMEOUT", + }) + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if len(entries) == 0 { + t.Fatal("case-insensitive query should match") + } +} + +func TestLogsTool_Limit(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "DEBUG", + "limit": float64(2), + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if len(entries) > 2 { + t.Errorf("expected at most 2 entries, got %d", len(entries)) + } +} + +func TestLogsTool_LimitMax(t *testing.T) { + tool := NewLogsTool() + + // limit > 300 should be capped + result := tool.Execute(context.Background(), map[string]any{ + "level": "DEBUG", + "limit": float64(999), + }) + + // Should not error, just cap silently + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } +} + +func TestLogsTool_FieldsSanitized(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "WARN", + "component": "wecom", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var entries []logger.LogEntry + if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + found := false + for _, e := range entries { + if e.Fields != nil && e.Fields["token"] != nil { + found = true + if e.Fields["token"] != "***" { + t.Errorf("token field should be sanitized, got %v", e.Fields["token"]) + } + if e.Fields["nonce"] != "safe-value" { + t.Errorf("nonce field should be preserved, got %v", e.Fields["nonce"]) + } + } + } + if !found { + t.Error("expected to find wecom entry with token field") + } +} + +func TestLogsTool_NoResults(t *testing.T) { + prev := logger.GetLevel() + defer logger.SetLevel(prev) + logger.SetLevel(logger.DEBUG) + + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{ + "level": "ERROR", + "component": "nonexistent-component-xyz", + }) + + if result.IsError { + t.Fatalf("should not be an error result: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No log entries found") { + t.Errorf("expected 'No log entries found' message, got: %s", result.ForLLM) + } +} + +func TestLogsTool_Silent(t *testing.T) { + setupTestLogs(t) + tool := NewLogsTool() + + result := tool.Execute(context.Background(), map[string]any{}) + if !result.Silent { + t.Error("logs tool result should be Silent") + } +} + +func TestLogsTool_ToolInterface(t *testing.T) { + tool := NewLogsTool() + + if tool.Name() != "logs" { + t.Errorf("expected name 'logs', got %q", tool.Name()) + } + if tool.Description() == "" { + t.Error("description should not be empty") + } + params := tool.Parameters() + if params == nil { + t.Error("parameters should not be nil") + } +}