Files
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

138 lines
3.7 KiB
Go

package core
import (
"bytes"
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"charm.land/fantasy"
)
type findArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewFindTool creates the find core tool.
func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
cfg := ApplyOptions(opts)
return &coreTool{
info: fantasy.ToolInfo{
Name: "find",
Description: "Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB.",
Parameters: map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
},
"path": map[string]any{
"type": "string",
"description": "Directory to search in (default: current directory)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of results (default: 1000)",
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeFind(ctx, call, cfg.WorkDir)
},
}
}
func executeFind(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
var args findArgs
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 := 1000
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
}
// Try fd first (faster, respects .gitignore by default)
result, err := findWithFd(ctx, args.Pattern, searchPath, limit)
if err == nil {
return result, nil
}
// Fall back to find + globbing
return findWithFind(ctx, args.Pattern, searchPath, limit)
}
func findWithFd(ctx context.Context, pattern, searchPath string, limit int) (fantasy.ToolResponse, error) {
fdArgs := []string{
"--glob", pattern,
"--hidden",
"--max-results", strconv.Itoa(limit),
".", // search current or specified path
}
cmd := exec.CommandContext(ctx, "fd", fdArgs...)
cmd.Dir = searchPath
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("fd failed: %w: %s", err, stderr.String())
}
output := strings.TrimSpace(stdout.String())
if output == "" {
return fantasy.NewTextResponse("No files found."), nil
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
func findWithFind(ctx context.Context, pattern, searchPath string, limit int) (fantasy.ToolResponse, error) {
// Use find with -name for simple patterns
findArgs := []string{searchPath, "-name", pattern, "-type", "f"}
cmd := exec.CommandContext(ctx, "find", findArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
_ = cmd.Run()
output := strings.TrimSpace(stdout.String())
if output == "" {
return fantasy.NewTextResponse("No files found."), nil
}
// Apply limit
lines := strings.Split(output, "\n")
if len(lines) > limit {
lines = lines[:limit]
output = strings.Join(lines, "\n")
output += fmt.Sprintf("\n[truncated: showing %d of more results]", limit)
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}