on main help sign in
feat(tools): add crush_logs tool for reading application logs

Implements a tool that reads Crush's own log files and returns formatted entries in compact text format. Supports: - Tail-style reading (last N entries, default 50, max 100) - Sensitive value redaction (token, secret, api_key, etc.) - JSON value formatting with proper escaping - Chronological ordering (oldest first) - Graceful handling of missing/empty files and malformed lines Approximate overhead: • Tool definition cost (always in prompt): ~1,000 bytes ≈ 250 tokens • Per invocation response (50 lines): ~4,000 bytes ≈ 1,000 tokens • Per invocation response (100 lines): ~8,000 bytes ≈ 2,000 tokens 💖 Generated with Crush Assisted-by: Kimi-K2.5 via Crush <crush@charm.land>

commit77fcc761173795afb85ef900f427e370c1fcb170 authorChristian Rocha <christian@rocha.is> dateSun 5 Apr 2026, 16:18 (6 months ago) parentdf39b64
6 files changed+1046−2
internal/agent/coordinator.go+4 internal/agent/tools/crush_logs.go+496 internal/agent/tools/crush_logs.md+13 internal/agent/tools/crush_logs_test.go+530 internal/config/config.go+1 internal/config/load_test.go+2−2
internal/agent/coordinator.go+4
@@ -12,6 +12,7 @@ import (
1212 "maps"
1313 "net/http"
1414 "os"
15+ "path/filepath"
1516 "slices"
1617 "strings"
1718
@@ -445,9 +446,12 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
445446 }
446447 }
447448
449+ logFile := filepath.Join(c.cfg.Config().Options.DataDirectory, "logs", "crush.log")
450+
448451 allTools = append(allTools,
449452 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
450453 tools.NewCrushInfoTool(c.cfg, c.lspManager),
454+ tools.NewCrushLogsTool(logFile),
451455 tools.NewJobOutputTool(),
452456 tools.NewJobKillTool(),
453457 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
internal/agent/tools/crush_logs.gonew file+496
@@ -0,0 +1,496 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "charm.land/fantasy"
17+)
18+
19+const CrushLogsToolName = "crush_logs"
20+
21+//go:embed crush_logs.md
22+var crushLogsDescription []byte
23+
24+// Max line size to prevent memory issues with very long log lines (1 MB).
25+const maxLogLineSize = 1024 * 1024
26+
27+// Default and max line limits.
28+const (
29+ defaultLogLines = 50
30+ maxLogLines = 100
31+)
32+
33+// Reserved fields that should not appear as extra key=value pairs.
34+// Case-insensitive matching is used.
35+var reservedFields = map[string]bool{
36+ "time": true,
37+ "level": true,
38+ "source": true,
39+ "msg": true,
40+}
41+
42+// Sensitive field keys that should be redacted (matched case-insensitively).
43+var sensitiveKeys = []string{
44+ "authorization",
45+ "api-key",
46+ "api_key",
47+ "apikey",
48+ "token",
49+ "secret",
50+ "password",
51+ "credential",
52+}
53+
54+type CrushLogsParams struct {
55+ Lines int `json:"lines,omitempty" description:"Number of recent log entries to return (default 50, max 100)"`
56+}
57+
58+func NewCrushLogsTool(logFile string) fantasy.AgentTool {
59+ return fantasy.NewAgentTool(
60+ CrushLogsToolName,
61+ string(crushLogsDescription),
62+ func(ctx context.Context, params CrushLogsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
63+ result := runCrushLogs(logFile, params)
64+ return fantasy.NewTextResponse(result), nil
65+ },
66+ )
67+}
68+
69+// runCrushLogs reads and formats the last N log entries from the given file.
70+func runCrushLogs(logFile string, params CrushLogsParams) string {
71+ // Validate and clamp the lines parameter.
72+ lines := params.Lines
73+ if lines <= 0 {
74+ lines = defaultLogLines
75+ }
76+ if lines > maxLogLines {
77+ lines = maxLogLines
78+ }
79+
80+ // Check if file exists.
81+ info, err := os.Stat(logFile)
82+ if err != nil {
83+ if os.IsNotExist(err) {
84+ return "No log file found"
85+ }
86+ return fmt.Sprintf("Error accessing log file: %v", err)
87+ }
88+
89+ if info.Size() == 0 {
90+ return "Log file is empty"
91+ }
92+
93+ // Read the last N lines from the log file.
94+ logEntries, err := readLastLines(logFile, lines)
95+ if err != nil {
96+ return fmt.Sprintf("Error reading log file: %v", err)
97+ }
98+
99+ if len(logEntries) == 0 {
100+ return "Log file is empty"
101+ }
102+
103+ // Format and return the entries.
104+ formatted := formatLogEntries(logEntries)
105+ return strings.Join(formatted, "\n")
106+}
107+
108+// readLastLines reads the last n lines from a file by seeking to the end and
109+// scanning backwards. Lines exceeding maxLogLineSize are skipped.
110+func readLastLines(filePath string, n int) ([]map[string]any, error) {
111+ file, err := os.Open(filePath)
112+ if err != nil {
113+ return nil, err
114+ }
115+ defer file.Close()
116+
117+ stat, err := file.Stat()
118+ if err != nil {
119+ return nil, err
120+ }
121+
122+ if stat.Size() == 0 {
123+ return nil, nil
124+ }
125+
126+ // Seek to end and read chunks backwards.
127+ var entries []map[string]any
128+ const chunkSize = 8192 // 8KB chunks
129+
130+ pos := stat.Size()
131+ var remainder []byte
132+
133+ for pos > 0 && len(entries) < n {
134+ chunkStart := max(pos-chunkSize, 0)
135+
136+ chunkLen := int(pos - chunkStart)
137+ if chunkLen == 0 {
138+ break
139+ }
140+
141+ _, err := file.Seek(chunkStart, 0)
142+ if err != nil {
143+ return nil, err
144+ }
145+
146+ chunk := make([]byte, chunkLen)
147+ _, err = io.ReadFull(file, chunk)
148+ if err != nil {
149+ return nil, err
150+ }
151+
152+ // Combine with remainder from previous (earlier) chunk.
153+ data := append(chunk, remainder...)
154+
155+ // Split into lines (without the final incomplete line if any).
156+ lines := splitLines(data)
157+
158+ // Keep the incomplete line for next iteration.
159+ if len(data) > 0 && data[len(data)-1] != '\n' {
160+ remainder = lines[len(lines)-1]
161+ lines = lines[:len(lines)-1]
162+ } else {
163+ remainder = nil
164+ }
165+
166+ // Parse lines from end to start to get most recent first.
167+ for i := len(lines) - 1; i >= 0; i-- {
168+ if len(lines[i]) > maxLogLineSize {
169+ // Skip oversized lines silently.
170+ continue
171+ }
172+
173+ // Try to parse as JSON.
174+ var entry map[string]any
175+ if err := json.Unmarshal(lines[i], &entry); err != nil {
176+ // Skip malformed lines silently.
177+ continue
178+ }
179+
180+ entries = append(entries, entry)
181+ if len(entries) >= n {
182+ break
183+ }
184+ }
185+
186+ pos = chunkStart
187+ }
188+
189+ // Handle final remainder.
190+ if len(remainder) > 0 && len(remainder) <= maxLogLineSize {
191+ var entry map[string]any
192+ if err := json.Unmarshal(remainder, &entry); err == nil {
193+ if len(entries) < n {
194+ entries = append(entries, entry)
195+ }
196+ }
197+ }
198+
199+ // Reverse to get chronological order (oldest first).
200+ for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
201+ entries[i], entries[j] = entries[j], entries[i]
202+ }
203+
204+ return entries, nil
205+}
206+
207+// splitLines splits data into lines without allocating strings.
208+func splitLines(data []byte) [][]byte {
209+ var lines [][]byte
210+ start := 0
211+ for i := range len(data) {
212+ if data[i] == '\n' {
213+ lines = append(lines, data[start:i])
214+ start = i + 1
215+ }
216+ }
217+ if start < len(data) {
218+ lines = append(lines, data[start:])
219+ }
220+ return lines
221+}
222+
223+// formatLogEntries formats log entries into compact text format.
224+func formatLogEntries(entries []map[string]any) []string {
225+ var result []string
226+ for _, entry := range entries {
227+ result = append(result, formatLogEntry(entry))
228+ }
229+ return result
230+}
231+
232+// formatLogEntry formats a single log entry into compact text format:
233+// TIMESTAMP LEVEL SOURCE:LINE MESSAGE key=value...
234+func formatLogEntry(entry map[string]any) string {
235+ var parts []string
236+
237+ // Extract and format timestamp (time-only, no date).
238+ timeStr := extractTime(entry)
239+ parts = append(parts, timeStr)
240+
241+ // Extract level.
242+ level := extractLevel(entry)
243+ parts = append(parts, level)
244+
245+ // Extract source.
246+ source := extractSource(entry)
247+ parts = append(parts, source)
248+
249+ // Extract message.
250+ msg := extractMessage(entry)
251+
252+ // Collect extra fields (excluding reserved fields).
253+ extraFields := extractExtraFields(entry)
254+
255+ // Build the output.
256+ var b strings.Builder
257+ for i, part := range parts {
258+ if i > 0 {
259+ b.WriteByte(' ')
260+ }
261+ b.WriteString(part)
262+ }
263+ b.WriteByte(' ')
264+ b.WriteString(msg)
265+
266+ // Append sorted key=value pairs.
267+ if len(extraFields) > 0 {
268+ keys := make([]string, 0, len(extraFields))
269+ for k := range extraFields {
270+ keys = append(keys, k)
271+ }
272+ sort.Strings(keys)
273+
274+ for _, k := range keys {
275+ b.WriteByte(' ')
276+ b.WriteString(k)
277+ b.WriteByte('=')
278+ b.WriteString(formatValue(extraFields[k], k))
279+ }
280+ }
281+
282+ return b.String()
283+}
284+
285+// extractTime extracts and formats the timestamp from a log entry.
286+// Returns time-only format (15:04:05).
287+func extractTime(entry map[string]any) string {
288+ timeVal, ok := entry["time"]
289+ if !ok {
290+ return "--:--:--"
291+ }
292+
293+ timeStr, ok := timeVal.(string)
294+ if !ok {
295+ return "--:--:--"
296+ }
297+
298+ // Parse RFC3339 format.
299+ t, err := time.Parse(time.RFC3339, timeStr)
300+ if err != nil {
301+ // Try other common formats.
302+ t, err = time.Parse("2006-01-02T15:04:05", timeStr)
303+ if err != nil {
304+ return "--:--:--"
305+ }
306+ }
307+
308+ return t.Format("15:04:05")
309+}
310+
311+// extractLevel extracts and normalizes the log level.
312+func extractLevel(entry map[string]any) string {
313+ levelVal, ok := entry["level"]
314+ if !ok {
315+ return "INFO"
316+ }
317+
318+ levelStr, ok := levelVal.(string)
319+ if !ok {
320+ return "INFO"
321+ }
322+
323+ switch strings.ToUpper(levelStr) {
324+ case "DEBUG":
325+ return "DEBUG"
326+ case "INFO":
327+ return "INFO"
328+ case "WARN", "WARNING":
329+ return "WARN"
330+ case "ERROR":
331+ return "ERROR"
332+ default:
333+ return "INFO"
334+ }
335+}
336+
337+// extractSource extracts the source file and line from a log entry.
338+func extractSource(entry map[string]any) string {
339+ sourceVal, ok := entry["source"]
340+ if !ok {
341+ return "unknown:0"
342+ }
343+
344+ // Source can be a string or an object with "file" and "line".
345+ switch s := sourceVal.(type) {
346+ case string:
347+ return filepath.Base(s)
348+ case map[string]any:
349+ fileVal, ok := s["file"].(string)
350+ if !ok {
351+ return "unknown:0"
352+ }
353+ fileVal = filepath.Base(fileVal)
354+
355+ lineNum := 0
356+ if lineVal, ok := s["line"]; ok {
357+ switch l := lineVal.(type) {
358+ case float64:
359+ lineNum = int(l)
360+ case int:
361+ lineNum = l
362+ case json.Number:
363+ if n, err := l.Int64(); err == nil {
364+ lineNum = int(n)
365+ }
366+ }
367+ }
368+ return fmt.Sprintf("%s:%d", fileVal, lineNum)
369+ default:
370+ return "unknown:0"
371+ }
372+}
373+
374+// extractMessage extracts the log message.
375+func extractMessage(entry map[string]any) string {
376+ msgVal, ok := entry["msg"]
377+ if !ok {
378+ return ""
379+ }
380+
381+ if msgStr, ok := msgVal.(string); ok {
382+ return msgStr
383+ }
384+
385+ return fmt.Sprintf("%v", msgVal)
386+}
387+
388+// extractExtraFields extracts all non-reserved fields from a log entry.
389+func extractExtraFields(entry map[string]any) map[string]any {
390+ result := make(map[string]any)
391+ for k, v := range entry {
392+ // Skip reserved fields (case-insensitive).
393+ if isReservedField(k) {
394+ continue
395+ }
396+ // Redact sensitive values.
397+ if isSensitiveKey(k) {
398+ result[k] = "[REDACTED]"
399+ } else {
400+ result[k] = v
401+ }
402+ }
403+ return result
404+}
405+
406+// isReservedField checks if a field name is reserved (case-insensitive).
407+func isReservedField(name string) bool {
408+ lowerName := strings.ToLower(name)
409+ return reservedFields[lowerName]
410+}
411+
412+// isSensitiveKey checks if a key contains sensitive information (case-insensitive).
413+func isSensitiveKey(name string) bool {
414+ lowerName := strings.ToLower(name)
415+ for _, sensitive := range sensitiveKeys {
416+ if strings.Contains(lowerName, sensitive) {
417+ return true
418+ }
419+ }
420+ return false
421+}
422+
423+// formatValue formats a value according to the quoting rules.
424+func formatValue(value any, key string) string {
425+ // Redact sensitive values (second check for safety).
426+ if isSensitiveKey(key) {
427+ return "[REDACTED]"
428+ }
429+
430+ switch v := value.(type) {
431+ case string:
432+ return formatStringValue(v)
433+ case float64:
434+ // Check if it's actually an integer.
435+ if v == float64(int64(v)) {
436+ return strconv.FormatInt(int64(v), 10)
437+ }
438+ return strconv.FormatFloat(v, 'f', -1, 64)
439+ case int:
440+ return strconv.Itoa(v)
441+ case int64:
442+ return strconv.FormatInt(v, 10)
443+ case bool:
444+ return strconv.FormatBool(v)
445+ case nil:
446+ return "null"
447+ case map[string]any, []any:
448+ // Objects and arrays are JSON-encoded and quoted.
449+ jsonBytes, err := json.Marshal(v)
450+ if err != nil {
451+ return quoteString(fmt.Sprintf("%v", v))
452+ }
453+ return quoteString(string(jsonBytes))
454+ default:
455+ return quoteString(fmt.Sprintf("%v", v))
456+ }
457+}
458+
459+// formatStringValue formats a string value with quoting if needed.
460+func formatStringValue(s string) string {
461+ // Quote if empty, contains spaces, =, newlines, or special chars.
462+ needsQuote := len(s) == 0 ||
463+ strings.ContainsAny(s, " =\n\r\t\"") ||
464+ strings.Contains(s, "\\")
465+
466+ if !needsQuote {
467+ return s
468+ }
469+
470+ return quoteString(s)
471+}
472+
473+// quoteString quotes a string with double quotes and escapes special characters.
474+func quoteString(s string) string {
475+ var b strings.Builder
476+ b.WriteByte('"')
477+ for i := 0; i < len(s); i++ {
478+ c := s[i]
479+ switch c {
480+ case '"':
481+ b.WriteString("\\\"")
482+ case '\\':
483+ b.WriteString("\\\\")
484+ case '\n':
485+ b.WriteString("\\n")
486+ case '\r':
487+ b.WriteString("\\r")
488+ case '\t':
489+ b.WriteString("\\t")
490+ default:
491+ b.WriteByte(c)
492+ }
493+ }
494+ b.WriteByte('"')
495+ return b.String()
496+}
internal/agent/tools/crush_logs.mdnew file+13
@@ -0,0 +1,13 @@
1+Read Crush's own application logs.
2+
3+<usage>
4+- Returns recent log entries from Crush's internal log file
5+- Use to diagnose issues with Crush itself (provider errors, tool failures,
6+ LSP problems, MCP connection issues)
7+- Entries shown in compact format: TIME LEVEL SOURCE MESSAGE key=value...
8+</usage>
9+
10+<tips>
11+- Default returns last 50 entries; use lines parameter for more (max 100)
12+- Look for ERROR and WARN entries first when diagnosing problems
13+</tips>
internal/agent/tools/crush_logs_test.gonew file+530
@@ -0,0 +1,530 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// createTestLogFile creates a temporary log file with the given entries.
16+func createTestLogFile(t *testing.T, entries []map[string]any) string {
17+ t.Helper()
18+ tempDir := t.TempDir()
19+ logFile := filepath.Join(tempDir, "crush.log")
20+
21+ file, err := os.Create(logFile)
22+ require.NoError(t, err)
23+ defer file.Close()
24+
25+ for _, entry := range entries {
26+ line, err := json.Marshal(entry)
27+ require.NoError(t, err)
28+ _, err = file.WriteString(string(line) + "\n")
29+ require.NoError(t, err)
30+ }
31+
32+ return logFile
33+}
34+
35+// makeLogEntry creates a standard log entry for testing.
36+func makeLogEntry(level, msg, source string, line int, extra map[string]any) map[string]any {
37+ entry := map[string]any{
38+ "time": time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
39+ "level": level,
40+ "msg": msg,
41+ "source": map[string]any{
42+ "file": source,
43+ "line": line,
44+ },
45+ }
46+ for k, v := range extra {
47+ entry[k] = v
48+ }
49+ return entry
50+}
51+
52+func TestNewCrushLogsTool(t *testing.T) {
53+ t.Parallel()
54+ tool := NewCrushLogsTool("/tmp/test.log")
55+ require.NotNil(t, tool)
56+ require.Equal(t, CrushLogsToolName, tool.Info().Name)
57+}
58+
59+func TestCrushLogs_HappyPath(t *testing.T) {
60+ t.Parallel()
61+ entries := []map[string]any{
62+ makeLogEntry("INFO", "Application started", "app.go", 42, map[string]any{"version": "1.0.0"}),
63+ makeLogEntry("DEBUG", "Processing request", "handler.go", 100, map[string]any{"request_id": "abc123"}),
64+ makeLogEntry("ERROR", "Database connection failed", "db.go", 55, map[string]any{"retry_count": 3}),
65+ }
66+
67+ logFile := createTestLogFile(t, entries)
68+
69+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
70+
71+ lines := strings.Split(result, "\n")
72+ require.Len(t, lines, 3)
73+
74+ // Verify format: TIMESTAMP LEVEL SOURCE:LINE MESSAGE
75+ require.Contains(t, lines[0], "INFO")
76+ require.Contains(t, lines[0], "app.go:42")
77+ require.Contains(t, lines[0], "Application started")
78+ require.Contains(t, lines[0], "version=1.0.0")
79+
80+ require.Contains(t, lines[1], "DEBUG")
81+ require.Contains(t, lines[1], "handler.go:100")
82+
83+ require.Contains(t, lines[2], "ERROR")
84+ require.Contains(t, lines[2], "db.go:55")
85+}
86+
87+func TestCrushLogs_DefaultLines(t *testing.T) {
88+ t.Parallel()
89+ // Create 100 log entries.
90+ var entries []map[string]any
91+ for i := 0; i < 100; i++ {
92+ entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
93+ }
94+
95+ logFile := createTestLogFile(t, entries)
96+
97+ // Call with Lines: 0 should default to 50.
98+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 0})
99+
100+ lines := strings.Split(result, "\n")
101+ require.Len(t, lines, 50)
102+
103+ // Verify we got the last 50 entries (entry 50-99).
104+ require.Contains(t, lines[0], "Entry 50")
105+ require.Contains(t, lines[49], "Entry 99")
106+}
107+
108+func TestCrushLogs_MaxCap(t *testing.T) {
109+ t.Parallel()
110+ // Create 200 log entries.
111+ var entries []map[string]any
112+ for i := 0; i < 200; i++ {
113+ entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
114+ }
115+
116+ logFile := createTestLogFile(t, entries)
117+
118+ // Request 200 lines, but should only get 100 (max cap).
119+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 200})
120+
121+ lines := strings.Split(result, "\n")
122+ require.Len(t, lines, 100)
123+}
124+
125+func TestCrushLogs_MissingFile(t *testing.T) {
126+ t.Parallel()
127+ result := runCrushLogs("/nonexistent/path/crush.log", CrushLogsParams{Lines: 50})
128+ require.Contains(t, result, "No log file found")
129+}
130+
131+func TestCrushLogs_EmptyFile(t *testing.T) {
132+ t.Parallel()
133+ tempDir := t.TempDir()
134+ logFile := filepath.Join(tempDir, "crush.log")
135+ _, err := os.Create(logFile)
136+ require.NoError(t, err)
137+
138+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 50})
139+ require.Contains(t, result, "Log file is empty")
140+}
141+
142+func TestCrushLogs_MalformedLines(t *testing.T) {
143+ t.Parallel()
144+ tempDir := t.TempDir()
145+ logFile := filepath.Join(tempDir, "crush.log")
146+
147+ file, err := os.Create(logFile)
148+ require.NoError(t, err)
149+
150+ // Write some valid and some invalid lines.
151+ validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
152+ line, _ := json.Marshal(validEntry)
153+ file.WriteString(string(line) + "\n")
154+ file.WriteString("this is not json\n")
155+ file.WriteString(`{"incomplete": "json` + "\n")
156+
157+ validEntry2 := makeLogEntry("INFO", "Another valid entry", "app.go", 2, nil)
158+ line2, _ := json.Marshal(validEntry2)
159+ file.WriteString(string(line2) + "\n")
160+
161+ file.Close()
162+
163+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
164+
165+ lines := strings.Split(result, "\n")
166+ // Only 2 valid lines should be returned.
167+ require.Len(t, lines, 2)
168+ require.Contains(t, lines[0], "Valid entry")
169+ require.Contains(t, lines[1], "Another valid entry")
170+}
171+
172+func TestCrushLogs_ExtraFieldsSorted(t *testing.T) {
173+ t.Parallel()
174+ entries := []map[string]any{
175+ makeLogEntry("INFO", "Test message", "app.go", 1, map[string]any{
176+ "z_field": "last",
177+ "a_field": "first",
178+ "m_field": "middle",
179+ }),
180+ }
181+
182+ logFile := createTestLogFile(t, entries)
183+
184+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
185+
186+ // Fields should be sorted alphabetically.
187+ idxA := strings.Index(result, "a_field=first")
188+ idxM := strings.Index(result, "m_field=middle")
189+ idxZ := strings.Index(result, "z_field=last")
190+
191+ require.True(t, idxA < idxM, "a_field should come before m_field")
192+ require.True(t, idxM < idxZ, "m_field should come before z_field")
193+}
194+
195+func TestCrushLogs_NonStringValues(t *testing.T) {
196+ t.Parallel()
197+ entry := map[string]any{
198+ "time": time.Now().Format(time.RFC3339),
199+ "level": "INFO",
200+ "msg": "Test values",
201+ "source": map[string]any{"file": "app.go", "line": 1},
202+ "count": 42,
203+ "ratio": 3.14,
204+ "active": true,
205+ "data": nil,
206+ "obj": map[string]any{"key": "value"},
207+ "arr": []any{1, 2, 3},
208+ }
209+
210+ logFile := createTestLogFile(t, []map[string]any{entry})
211+
212+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
213+
214+ // Numbers should be bare (not quoted).
215+ require.Contains(t, result, "count=42")
216+ require.Contains(t, result, "ratio=3.14")
217+
218+ // Booleans should be bare.
219+ require.Contains(t, result, "active=true")
220+
221+ // Null should be bare.
222+ require.Contains(t, result, "data=null")
223+
224+ // Objects and arrays should be JSON-encoded and quoted.
225+ require.Contains(t, result, `obj="{`)
226+ require.Contains(t, result, `arr="[`)
227+}
228+
229+func TestCrushLogs_Redaction(t *testing.T) {
230+ t.Parallel()
231+ entries := []map[string]any{
232+ makeLogEntry("INFO", "API call", "api.go", 10, map[string]any{
233+ "authorization": "Bearer secret123",
234+ "api_key": "my-api-key",
235+ "api-key": "my-api-key-2",
236+ "apikey": "myapikey",
237+ "token": "mytoken",
238+ "secret": "mysecret",
239+ "password": "mypassword",
240+ "credential": "mycred",
241+ "Authorization": "Bearer secret456",
242+ "API_KEY": "uppercase",
243+ "my_token_value": "nestedtoken",
244+ }),
245+ }
246+
247+ logFile := createTestLogFile(t, entries)
248+
249+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
250+
251+ // All sensitive fields should be redacted.
252+ require.Contains(t, result, "authorization=[REDACTED]")
253+ require.Contains(t, result, "api_key=[REDACTED]")
254+ require.Contains(t, result, "api-key=[REDACTED]")
255+ require.Contains(t, result, "apikey=[REDACTED]")
256+ require.Contains(t, result, "token=[REDACTED]")
257+ require.Contains(t, result, "secret=[REDACTED]")
258+ require.Contains(t, result, "password=[REDACTED]")
259+ require.Contains(t, result, "credential=[REDACTED]")
260+ require.Contains(t, result, "Authorization=[REDACTED]")
261+ require.Contains(t, result, "API_KEY=[REDACTED]")
262+ require.Contains(t, result, "my_token_value=[REDACTED]")
263+
264+ // Original sensitive values should not appear.
265+ require.NotContains(t, result, "secret123")
266+ require.NotContains(t, result, "my-api-key")
267+ require.NotContains(t, result, "mytoken")
268+}
269+
270+func TestCrushLogs_ReservedFields(t *testing.T) {
271+ t.Parallel()
272+ entries := []map[string]any{
273+ {
274+ "time": time.Now().Format(time.RFC3339),
275+ "level": "INFO",
276+ "msg": "Test",
277+ "source": map[string]any{"file": "app.go", "line": 1},
278+ "Time": "should be reserved",
279+ "LEVEL": "should be reserved",
280+ "Msg": "should be reserved",
281+ "SOURCE": "should be reserved",
282+ "extra": "should appear",
283+ },
284+ }
285+
286+ logFile := createTestLogFile(t, entries)
287+
288+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
289+
290+ // Reserved fields (case-insensitive) should not appear in extra fields.
291+ require.NotContains(t, result, "Time=")
292+ require.NotContains(t, result, "LEVEL=")
293+ require.NotContains(t, result, "Msg=")
294+ require.NotContains(t, result, "SOURCE=")
295+ require.NotContains(t, result, "time=") // The extra time field
296+ require.NotContains(t, result, "level=") // The extra level field
297+
298+ // Non-reserved field should appear (quoted since it has spaces).
299+ require.Contains(t, result, `extra="should appear"`)
300+}
301+
302+func TestCrushLogs_OversizedLines(t *testing.T) {
303+ t.Parallel()
304+ tempDir := t.TempDir()
305+ logFile := filepath.Join(tempDir, "crush.log")
306+
307+ file, err := os.Create(logFile)
308+ require.NoError(t, err)
309+
310+ // Create a valid entry first.
311+ validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
312+ line, _ := json.Marshal(validEntry)
313+ file.WriteString(string(line) + "\n")
314+
315+ // Create an oversized line (more than 1 MB).
316+ bigValue := strings.Repeat("x", maxLogLineSize+1000)
317+ bigEntry := map[string]any{
318+ "time": time.Now().Format(time.RFC3339),
319+ "level": "INFO",
320+ "msg": "Big message",
321+ "source": map[string]any{"file": "big.go", "line": 1},
322+ "data": bigValue,
323+ }
324+ bigLine, _ := json.Marshal(bigEntry)
325+ file.WriteString(string(bigLine) + "\n")
326+
327+ // Create another valid entry.
328+ validEntry2 := makeLogEntry("INFO", "Second valid entry", "app.go", 2, nil)
329+ line2, _ := json.Marshal(validEntry2)
330+ file.WriteString(string(line2) + "\n")
331+
332+ file.Close()
333+
334+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
335+
336+ lines := strings.Split(result, "\n")
337+
338+ // Only the 2 valid entries should be returned (oversized one skipped).
339+ require.Len(t, lines, 2)
340+ require.Contains(t, lines[0], "Valid entry")
341+ require.Contains(t, lines[1], "Second valid entry")
342+}
343+
344+func TestCrushLogs_PartialTrailingLine(t *testing.T) {
345+ t.Parallel()
346+ tempDir := t.TempDir()
347+ logFile := filepath.Join(tempDir, "crush.log")
348+
349+ file, err := os.Create(logFile)
350+ require.NoError(t, err)
351+
352+ // Create valid entries.
353+ for i := 0; i < 5; i++ {
354+ entry := makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil)
355+ line, _ := json.Marshal(entry)
356+ file.WriteString(string(line) + "\n")
357+ }
358+
359+ // Write a partial/truncated line (no closing brace or newline).
360+ file.WriteString(`{"time": "2024-01-15T10:00:00Z", "level": "INFO", "msg": "Truncated`)
361+ file.Close()
362+
363+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
364+
365+ lines := strings.Split(result, "\n")
366+
367+ // Should get the 5 valid entries, truncated line is skipped.
368+ require.Len(t, lines, 5)
369+ for i, line := range lines {
370+ require.Contains(t, line, fmt.Sprintf("Entry %d", i))
371+ }
372+}
373+
374+func TestCrushLogs_ValueQuoting(t *testing.T) {
375+ t.Parallel()
376+ entries := []map[string]any{
377+ makeLogEntry("INFO", "Test", "app.go", 1, map[string]any{
378+ "empty": "",
379+ "with_spaces": "hello world",
380+ "with_equals": "a=b",
381+ "with_newline": "line1\nline2",
382+ "with_quote": `say "hello"`,
383+ "with_backslash": "path\\to\\file",
384+ "normal": "simplevalue",
385+ }),
386+ }
387+
388+ logFile := createTestLogFile(t, entries)
389+
390+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
391+
392+ // Empty strings should be quoted.
393+ require.Contains(t, result, `empty=""`)
394+
395+ // Strings with spaces should be quoted.
396+ require.Contains(t, result, `with_spaces="hello world"`)
397+
398+ // Strings with = should be quoted.
399+ require.Contains(t, result, `with_equals="a=b"`)
400+
401+ // Strings with newlines should escape them.
402+ require.Contains(t, result, `with_newline="line1\nline2"`)
403+
404+ // Strings with quotes should escape them.
405+ require.Contains(t, result, `with_quote="say \"hello\""`)
406+
407+ // Strings with backslashes should escape them.
408+ require.Contains(t, result, `with_backslash="path\\to\\file"`)
409+
410+ // Normal strings without special chars should be bare.
411+ require.Contains(t, result, "normal=simplevalue")
412+}
413+
414+func TestCrushLogs_ChronologicalOrder(t *testing.T) {
415+ t.Parallel()
416+ // Create entries with different timestamps.
417+ baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
418+ entries := []map[string]any{
419+ {
420+ "time": baseTime.Add(0 * time.Second).Format(time.RFC3339),
421+ "level": "INFO",
422+ "msg": "First",
423+ "source": map[string]any{"file": "app.go", "line": 1},
424+ },
425+ {
426+ "time": baseTime.Add(1 * time.Second).Format(time.RFC3339),
427+ "level": "INFO",
428+ "msg": "Second",
429+ "source": map[string]any{"file": "app.go", "line": 2},
430+ },
431+ {
432+ "time": baseTime.Add(2 * time.Second).Format(time.RFC3339),
433+ "level": "INFO",
434+ "msg": "Third",
435+ "source": map[string]any{"file": "app.go", "line": 3},
436+ },
437+ }
438+
439+ logFile := createTestLogFile(t, entries)
440+
441+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
442+
443+ lines := strings.Split(result, "\n")
444+
445+ // Verify chronological order (oldest first).
446+ require.Len(t, lines, 3)
447+ require.Contains(t, lines[0], "First")
448+ require.Contains(t, lines[1], "Second")
449+ require.Contains(t, lines[2], "Third")
450+}
451+
452+func TestCrushLogs_TimeOnlyFormat(t *testing.T) {
453+ t.Parallel()
454+ entry := map[string]any{
455+ "time": "2024-01-15T15:04:05Z",
456+ "level": "INFO",
457+ "msg": "Test",
458+ "source": map[string]any{"file": "app.go", "line": 1},
459+ }
460+
461+ logFile := createTestLogFile(t, []map[string]any{entry})
462+
463+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
464+
465+ // Should show time-only format.
466+ require.True(t, strings.HasPrefix(result, "15:04:05"), "Expected time-only format, got: %s", result)
467+}
468+
469+func TestCrushLogs_LevelVariations(t *testing.T) {
470+ t.Parallel()
471+ entries := []map[string]any{
472+ makeLogEntry("DEBUG", "Debug message", "app.go", 1, nil),
473+ makeLogEntry("INFO", "Info message", "app.go", 2, nil),
474+ makeLogEntry("WARN", "Warn message", "app.go", 3, nil),
475+ makeLogEntry("WARNING", "Warning message", "app.go", 4, nil),
476+ makeLogEntry("ERROR", "Error message", "app.go", 5, nil),
477+ }
478+
479+ logFile := createTestLogFile(t, entries)
480+
481+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 5})
482+
483+ lines := strings.Split(result, "\n")
484+ require.Len(t, lines, 5)
485+
486+ // Check level normalization.
487+ require.Contains(t, lines[0], "DEBUG")
488+ require.Contains(t, lines[1], "INFO")
489+ require.Contains(t, lines[2], "WARN")
490+ require.Contains(t, lines[3], "WARN") // WARNING -> WARN
491+ require.Contains(t, lines[4], "ERROR")
492+}
493+
494+func TestCrushLogs_SourceVariations(t *testing.T) {
495+ t.Parallel()
496+ entries := []map[string]any{
497+ // Source as object with file and line.
498+ {
499+ "time": time.Now().Format(time.RFC3339),
500+ "level": "INFO",
501+ "msg": "Object source",
502+ "source": map[string]any{"file": "/path/to/app.go", "line": 42},
503+ },
504+ // Source as string.
505+ {
506+ "time": time.Now().Format(time.RFC3339),
507+ "level": "INFO",
508+ "msg": "String source",
509+ "source": "/path/to/handler.go:100",
510+ },
511+ // Missing source.
512+ {
513+ "time": time.Now().Format(time.RFC3339),
514+ "level": "INFO",
515+ "msg": "No source",
516+ },
517+ }
518+
519+ logFile := createTestLogFile(t, entries)
520+
521+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
522+
523+ lines := strings.Split(result, "\n")
524+ require.Len(t, lines, 3)
525+
526+ // Check source formatting (should use basename only).
527+ require.Contains(t, lines[0], "app.go:42")
528+ require.Contains(t, lines[1], "handler.go") // String source gets basename too
529+ require.Contains(t, lines[2], "unknown:0") // Missing source
530+}
internal/config/config.go+1
@@ -463,6 +463,7 @@ func allToolNames() []string {
463463 "agent",
464464 "bash",
465465 "crush_info",
466+ "crush_logs",
466467 "job_output",
467468 "job_kill",
468469 "download",
internal/config/load_test.go+2−2
@@ -490,7 +490,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
490490 coderAgent, ok := cfg.Agents[AgentCoder]
491491 require.True(t, ok)
492492
493− assert.Equal(t, []string{"agent", "bash", "crush_info", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
493+ assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
494494
495495 taskAgent, ok := cfg.Agents[AgentTask]
496496 require.True(t, ok)
@@ -513,7 +513,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
513513 cfg.SetupAgents()
514514 coderAgent, ok := cfg.Agents[AgentCoder]
515515 require.True(t, ok)
516− assert.Equal(t, []string{"agent", "bash", "crush_info", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
516+ assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
517517
518518 taskAgent, ok := cfg.Agents[AgentTask]
519519 require.True(t, ok)