Files
kit/internal/core/grep.go
T
Ed Zynda 424847f0db feat: enable parallel tool execution with multi-tool status display
- Mark read-only core tools as parallel-safe (read, grep, find, ls)
- Mark spawn_subagent as parallel-safe for concurrent task delegation
- Update UI to track multiple active tools during parallel execution
- Display 'Running: tool1, tool2, ...' in spinner for concurrent tools
- Add test for parallel tool execution scenarios

Fantasy already supports parallel execution via ToolInfo.Parallel field.
Tools marked parallel run concurrently (up to 5 at a time).
2026-03-14 17:24:20 +03:00

188 lines
5.1 KiB
Go

package core
import (
"bytes"
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"charm.land/fantasy"
)
type grepArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"`
Glob string `json:"glob,omitempty"`
IgnoreCase bool `json:"ignore_case,omitempty"`
Literal bool `json:"literal,omitempty"`
Context int `json:"context,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewGrepTool creates the grep core tool.
func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
cfg := ApplyOptions(opts)
return &coreTool{
info: fantasy.ToolInfo{
Name: "grep",
Description: "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB. Long lines are truncated to 500 chars.",
Parameters: map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Search pattern (regex or literal string)",
},
"path": map[string]any{
"type": "string",
"description": "Directory or file to search (default: current directory)",
},
"glob": map[string]any{
"type": "string",
"description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
},
"ignore_case": map[string]any{
"type": "boolean",
"description": "Case-insensitive search (default: false)",
},
"literal": map[string]any{
"type": "boolean",
"description": "Treat pattern as literal string instead of regex (default: false)",
},
"context": map[string]any{
"type": "number",
"description": "Number of context lines before and after each match (default: 0)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of matches to return (default: 100)",
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeGrep(ctx, call, cfg.WorkDir)
},
}
}
func executeGrep(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
var args grepArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
if args.Pattern == "" {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
limit := 100
if args.Limit > 0 {
limit = args.Limit
}
searchPath := "."
if args.Path != "" {
resolved, err := resolvePathWithWorkDir(args.Path, workDir)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
searchPath = resolved
} else if workDir != "" {
searchPath = workDir
}
// Build ripgrep command
rgArgs := []string{
"--line-number",
"--no-heading",
"--color=never",
"--max-count=" + strconv.Itoa(limit),
}
if args.IgnoreCase {
rgArgs = append(rgArgs, "--ignore-case")
}
if args.Literal {
rgArgs = append(rgArgs, "--fixed-strings")
}
if args.Context > 0 {
rgArgs = append(rgArgs, fmt.Sprintf("--context=%d", args.Context))
}
if args.Glob != "" {
rgArgs = append(rgArgs, "--glob="+args.Glob)
}
rgArgs = append(rgArgs, args.Pattern, searchPath)
cmd := exec.CommandContext(ctx, "rg", rgArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// rg exits with 1 when no matches found (not an error)
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.ExitCode() == 1 {
return fantasy.NewTextResponse("No matches found."), nil
}
if exitErr.ExitCode() == 2 {
return fantasy.NewTextErrorResponse(fmt.Sprintf("grep error: %s", stderr.String())), nil
}
}
// rg not found — fall back to grep
return grepFallback(ctx, args, searchPath, limit)
}
output := stdout.String()
if output == "" {
return fantasy.NewTextResponse("No matches found."), nil
}
// Truncate long lines
lines := strings.Split(output, "\n")
for i, line := range lines {
lines[i] = truncateLine(line, grepMaxLineLen)
}
output = strings.Join(lines, "\n")
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// grepFallback uses standard grep when rg is not available.
func grepFallback(ctx context.Context, args grepArgs, searchPath string, limit int) (fantasy.ToolResponse, error) {
grepArgs := []string{"-rn", "--color=never"}
if args.IgnoreCase {
grepArgs = append(grepArgs, "-i")
}
if args.Literal {
grepArgs = append(grepArgs, "-F")
}
if args.Context > 0 {
grepArgs = append(grepArgs, fmt.Sprintf("-C%d", args.Context))
}
if args.Glob != "" {
grepArgs = append(grepArgs, "--include="+args.Glob)
}
grepArgs = append(grepArgs, args.Pattern, searchPath)
cmd := exec.CommandContext(ctx, "grep", grepArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
_ = cmd.Run()
output := stdout.String()
if output == "" {
return fantasy.NewTextResponse("No matches found."), nil
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}