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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 05:09:32 +09:00
parent 60e0707a83
commit 568bdcb0cd
3 changed files with 362 additions and 0 deletions

View file

@ -61,6 +61,7 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewBgMonitorTool(execTool)) toolsRegistry.Register(tools.NewBgMonitorTool(execTool))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewLogsTool())
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)

99
pkg/tools/logs.go Normal file
View file

@ -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))
}

262
pkg/tools/logs_test.go Normal file
View file

@ -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")
}
}