1package tools
2
3import (
4 "context"
5 _ "embed"
6 "encoding/json"
7 "fmt"
8 "html/template"
9 "io"
10 "os"
11 "path/filepath"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "charm.land/fantasy"
18)
19
20const CrushLogsToolName = "crush_logs"
21
22//go:embed crush_logs.md.tpl
23var crushLogsDescriptionTmpl []byte
24
25var crushLogsDescriptionTpl = template.Must(
26 template.New("crushLogsDescription").
27 Parse(string(crushLogsDescriptionTmpl)),
28)
29
30type crushLogsDescriptionData struct {
31 DefaultLines int
32 MaxLines int
33}
34
35func crushLogsDescription() string {
36 return renderTemplate(crushLogsDescriptionTpl, crushLogsDescriptionData{
37 DefaultLines: defaultLogLines,
38 MaxLines: maxLogLines,
39 })
40}
41
42// Max line size to prevent memory issues with very long log lines (1 MB).
43const maxLogLineSize = 1024 * 1024
44
45// Default and max line limits.
46const (
47 defaultLogLines = 50
48 maxLogLines = 100
49)
50
51// Reserved fields that should not appear as extra key=value pairs.
52// Case-insensitive matching is used.
53var reservedFields = map[string]bool{
54 "time": true,
55 "level": true,
56 "source": true,
57 "msg": true,
58}
59
60// Sensitive field keys that should be redacted (matched case-insensitively).
61var sensitiveKeys = []string{
62 "authorization",
63 "api-key",
64 "api_key",
65 "apikey",
66 "token",
67 "secret",
68 "password",
69 "credential",
70}
71
72type CrushLogsParams struct {
73 Lines int `json:"lines,omitempty" description:"Number of recent log entries to return (default 50, max 100)"`
74}
75
76func NewCrushLogsTool(logFile string) fantasy.AgentTool {
77 return fantasy.NewAgentTool(
78 CrushLogsToolName,
79 crushLogsDescription(),
80 func(ctx context.Context, params CrushLogsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
81 result := runCrushLogs(logFile, params)
82 return fantasy.NewTextResponse(result), nil
83 },
84 )
85}
86
87// runCrushLogs reads and formats the last N log entries from the given file.
88func runCrushLogs(logFile string, params CrushLogsParams) string {
89 // Validate and clamp the lines parameter.
90 lines := params.Lines
91 if lines <= 0 {
92 lines = defaultLogLines
93 }
94 if lines > maxLogLines {
95 lines = maxLogLines
96 }
97
98 // Check if file exists.
99 info, err := os.Stat(logFile)
100 if err != nil {
101 if os.IsNotExist(err) {
102 return "No log file found"
103 }
104 return fmt.Sprintf("Error accessing log file: %v", err)
105 }
106
107 if info.Size() == 0 {
108 return "Log file is empty"
109 }
110
111 // Read the last N lines from the log file.
112 logEntries, err := readLastLines(logFile, lines)
113 if err != nil {
114 return fmt.Sprintf("Error reading log file: %v", err)
115 }
116
117 if len(logEntries) == 0 {
118 return "Log file is empty"
119 }
120
121 // Format and return the entries.
122 formatted := formatLogEntries(logEntries)
123 return strings.Join(formatted, "\n")
124}
125
126// readLastLines reads the last n lines from a file by seeking to the end and
127// scanning backwards. Lines exceeding maxLogLineSize are skipped.
128func readLastLines(filePath string, n int) ([]map[string]any, error) {
129 file, err := os.Open(filePath)
130 if err != nil {
131 return nil, err
132 }
133 defer file.Close()
134
135 stat, err := file.Stat()
136 if err != nil {
137 return nil, err
138 }
139
140 if stat.Size() == 0 {
141 return nil, nil
142 }
143
144 // Seek to end and read chunks backwards.
145 var entries []map[string]any
146 const chunkSize = 8192 // 8KB chunks
147
148 pos := stat.Size()
149 var remainder []byte
150
151 for pos > 0 && len(entries) < n {
152 chunkStart := max(pos-chunkSize, 0)
153
154 chunkLen := int(pos - chunkStart)
155 if chunkLen == 0 {
156 break
157 }
158
159 _, err := file.Seek(chunkStart, 0)
160 if err != nil {
161 return nil, err
162 }
163
164 chunk := make([]byte, chunkLen)
165 _, err = io.ReadFull(file, chunk)
166 if err != nil {
167 return nil, err
168 }
169
170 // Combine with remainder from previous (earlier) chunk.
171 data := append(chunk, remainder...)
172
173 // Split into lines (without the final incomplete line if any).
174 lines := splitLines(data)
175
176 // Keep the incomplete line for next iteration.
177 if len(data) > 0 && data[len(data)-1] != '\n' {
178 remainder = lines[len(lines)-1]
179 lines = lines[:len(lines)-1]
180 } else {
181 remainder = nil
182 }
183
184 // Parse lines from end to start to get most recent first.
185 for i := len(lines) - 1; i >= 0; i-- {
186 if len(lines[i]) > maxLogLineSize {
187 // Skip oversized lines silently.
188 continue
189 }
190
191 // Try to parse as JSON.
192 var entry map[string]any
193 if err := json.Unmarshal(lines[i], &entry); err != nil {
194 // Skip malformed lines silently.
195 continue
196 }
197
198 entries = append(entries, entry)
199 if len(entries) >= n {
200 break
201 }
202 }
203
204 pos = chunkStart
205 }
206
207 // Handle final remainder.
208 if len(remainder) > 0 && len(remainder) <= maxLogLineSize {
209 var entry map[string]any
210 if err := json.Unmarshal(remainder, &entry); err == nil {
211 if len(entries) < n {
212 entries = append(entries, entry)
213 }
214 }
215 }
216
217 // Reverse to get chronological order (oldest first).
218 for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
219 entries[i], entries[j] = entries[j], entries[i]
220 }
221
222 return entries, nil
223}
224
225// splitLines splits data into lines without allocating strings.
226func splitLines(data []byte) [][]byte {
227 var lines [][]byte
228 start := 0
229 for i := range len(data) {
230 if data[i] == '\n' {
231 lines = append(lines, data[start:i])
232 start = i + 1
233 }
234 }
235 if start < len(data) {
236 lines = append(lines, data[start:])
237 }
238 return lines
239}
240
241// formatLogEntries formats log entries into compact text format.
242func formatLogEntries(entries []map[string]any) []string {
243 var result []string
244 for _, entry := range entries {
245 result = append(result, formatLogEntry(entry))
246 }
247 return result
248}
249
250// formatLogEntry formats a single log entry into compact text format:
251// TIMESTAMP LEVEL SOURCE:LINE MESSAGE key=value...
252func formatLogEntry(entry map[string]any) string {
253 var parts []string
254
255 // Extract and format timestamp (time-only, no date).
256 timeStr := extractTime(entry)
257 parts = append(parts, timeStr)
258
259 // Extract level.
260 level := extractLevel(entry)
261 parts = append(parts, level)
262
263 // Extract source.
264 source := extractSource(entry)
265 parts = append(parts, source)
266
267 // Extract message.
268 msg := extractMessage(entry)
269
270 // Collect extra fields (excluding reserved fields).
271 extraFields := extractExtraFields(entry)
272
273 // Build the output.
274 var b strings.Builder
275 for i, part := range parts {
276 if i > 0 {
277 b.WriteByte(' ')
278 }
279 b.WriteString(part)
280 }
281 b.WriteByte(' ')
282 b.WriteString(msg)
283
284 // Append sorted key=value pairs.
285 if len(extraFields) > 0 {
286 keys := make([]string, 0, len(extraFields))
287 for k := range extraFields {
288 keys = append(keys, k)
289 }
290 sort.Strings(keys)
291
292 for _, k := range keys {
293 b.WriteByte(' ')
294 b.WriteString(k)
295 b.WriteByte('=')
296 b.WriteString(formatValue(extraFields[k], k))
297 }
298 }
299
300 return b.String()
301}
302
303// extractTime extracts and formats the timestamp from a log entry.
304// Returns time-only format (15:04:05).
305func extractTime(entry map[string]any) string {
306 timeVal, ok := entry["time"]
307 if !ok {
308 return "--:--:--"
309 }
310
311 timeStr, ok := timeVal.(string)
312 if !ok {
313 return "--:--:--"
314 }
315
316 // Parse RFC3339 format.
317 t, err := time.Parse(time.RFC3339, timeStr)
318 if err != nil {
319 // Try other common formats.
320 t, err = time.Parse("2006-01-02T15:04:05", timeStr)
321 if err != nil {
322 return "--:--:--"
323 }
324 }
325
326 return t.Format("15:04:05")
327}
328
329// extractLevel extracts and normalizes the log level.
330func extractLevel(entry map[string]any) string {
331 levelVal, ok := entry["level"]
332 if !ok {
333 return "INFO"
334 }
335
336 levelStr, ok := levelVal.(string)
337 if !ok {
338 return "INFO"
339 }
340
341 switch strings.ToUpper(levelStr) {
342 case "DEBUG":
343 return "DEBUG"
344 case "INFO":
345 return "INFO"
346 case "WARN", "WARNING":
347 return "WARN"
348 case "ERROR":
349 return "ERROR"
350 default:
351 return "INFO"
352 }
353}
354
355// extractSource extracts the source file and line from a log entry.
356func extractSource(entry map[string]any) string {
357 sourceVal, ok := entry["source"]
358 if !ok {
359 return "unknown:0"
360 }
361
362 // Source can be a string or an object with "file" and "line".
363 switch s := sourceVal.(type) {
364 case string:
365 return filepath.Base(s)
366 case map[string]any:
367 fileVal, ok := s["file"].(string)
368 if !ok {
369 return "unknown:0"
370 }
371 fileVal = filepath.Base(fileVal)
372
373 lineNum := 0
374 if lineVal, ok := s["line"]; ok {
375 switch l := lineVal.(type) {
376 case float64:
377 lineNum = int(l)
378 case int:
379 lineNum = l
380 case json.Number:
381 if n, err := l.Int64(); err == nil {
382 lineNum = int(n)
383 }
384 }
385 }
386 return fmt.Sprintf("%s:%d", fileVal, lineNum)
387 default:
388 return "unknown:0"
389 }
390}
391
392// extractMessage extracts the log message.
393func extractMessage(entry map[string]any) string {
394 msgVal, ok := entry["msg"]
395 if !ok {
396 return ""
397 }
398
399 if msgStr, ok := msgVal.(string); ok {
400 return msgStr
401 }
402
403 return fmt.Sprintf("%v", msgVal)
404}
405
406// extractExtraFields extracts all non-reserved fields from a log entry.
407func extractExtraFields(entry map[string]any) map[string]any {
408 result := make(map[string]any)
409 for k, v := range entry {
410 // Skip reserved fields (case-insensitive).
411 if isReservedField(k) {
412 continue
413 }
414 // Redact sensitive values.
415 if isSensitiveKey(k) {
416 result[k] = "[REDACTED]"
417 } else {
418 result[k] = v
419 }
420 }
421 return result
422}
423
424// isReservedField checks if a field name is reserved (case-insensitive).
425func isReservedField(name string) bool {
426 lowerName := strings.ToLower(name)
427 return reservedFields[lowerName]
428}
429
430// isSensitiveKey checks if a key contains sensitive information (case-insensitive).
431func isSensitiveKey(name string) bool {
432 lowerName := strings.ToLower(name)
433 for _, sensitive := range sensitiveKeys {
434 if strings.Contains(lowerName, sensitive) {
435 return true
436 }
437 }
438 return false
439}
440
441// formatValue formats a value according to the quoting rules.
442func formatValue(value any, key string) string {
443 // Redact sensitive values (second check for safety).
444 if isSensitiveKey(key) {
445 return "[REDACTED]"
446 }
447
448 switch v := value.(type) {
449 case string:
450 return formatStringValue(v)
451 case float64:
452 // Check if it's actually an integer.
453 if v == float64(int64(v)) {
454 return strconv.FormatInt(int64(v), 10)
455 }
456 return strconv.FormatFloat(v, 'f', -1, 64)
457 case int:
458 return strconv.Itoa(v)
459 case int64:
460 return strconv.FormatInt(v, 10)
461 case bool:
462 return strconv.FormatBool(v)
463 case nil:
464 return "null"
465 case map[string]any, []any:
466 // Objects and arrays are JSON-encoded and quoted.
467 jsonBytes, err := json.Marshal(v)
468 if err != nil {
469 return quoteString(fmt.Sprintf("%v", v))
470 }
471 return quoteString(string(jsonBytes))
472 default:
473 return quoteString(fmt.Sprintf("%v", v))
474 }
475}
476
477// formatStringValue formats a string value with quoting if needed.
478func formatStringValue(s string) string {
479 // Quote if empty, contains spaces, =, newlines, or special chars.
480 needsQuote := len(s) == 0 ||
481 strings.ContainsAny(s, " =\n\r\t\"") ||
482 strings.Contains(s, "\\")
483
484 if !needsQuote {
485 return s
486 }
487
488 return quoteString(s)
489}
490
491// quoteString quotes a string with double quotes and escapes special characters.
492func quoteString(s string) string {
493 var b strings.Builder
494 b.WriteByte('"')
495 for i := 0; i < len(s); i++ {
496 c := s[i]
497 switch c {
498 case '"':
499 b.WriteString("\\\"")
500 case '\\':
501 b.WriteString("\\\\")
502 case '\n':
503 b.WriteString("\\n")
504 case '\r':
505 b.WriteString("\\r")
506 case '\t':
507 b.WriteString("\\t")
508 default:
509 b.WriteByte(c)
510 }
511 }
512 b.WriteByte('"')
513 return b.String()
514}