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:
parent
4356bf4fa2
commit
b65876896e
3 changed files with 362 additions and 0 deletions
|
|
@ -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
99
pkg/tools/logs.go
Normal 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
262
pkg/tools/logs_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue