- Add bubbletea/lipgloss/glamour dependencies for TUI - Create internal/tui package with EventWriter, styles, and bubbletea Model - Support streaming output display in conversation window - Add right panel with statistics and active agent status - Implement multi-agent collaboration with sub-agents - Add AgentCallTool for delegating tasks to sub-agents - Support parallel tool execution with goroutines - Auto-discover sub-agents from ~/.orca/prompts/ directory - Fix orchestrator routing based on msg.To field - Add non-blocking event writer with timeout to prevent blocking
434 lines
12 KiB
Go
434 lines
12 KiB
Go
package tool
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/orca/orca/pkg/sandbox"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// exec — Execute a shell command via the sandbox
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// execTool runs shell commands through the ProcessSandbox.
|
|
type execTool struct {
|
|
sandbox sandbox.Sandbox
|
|
}
|
|
|
|
// NewExecTool creates a new exec tool backed by the given sandbox.
|
|
func NewExecTool(sb sandbox.Sandbox) Tool {
|
|
if sb == nil {
|
|
sb = sandbox.NewProcessSandbox()
|
|
}
|
|
return &execTool{sandbox: sb}
|
|
}
|
|
|
|
func (t *execTool) Name() string { return "exec" }
|
|
|
|
func (t *execTool) Description() string {
|
|
return "Execute a shell command and return its output. Use this for running scripts, " +
|
|
"installing packages, compiling code, or any command-line operation."
|
|
}
|
|
|
|
func (t *execTool) Parameters() map[string]ParameterSchema {
|
|
return map[string]ParameterSchema{
|
|
"command": {
|
|
Type: "string",
|
|
Description: "The shell command to execute (e.g., 'ls -la' or 'python script.py')",
|
|
Required: true,
|
|
},
|
|
"timeout": {
|
|
Type: "number",
|
|
Description: "Timeout in seconds for the command execution (default: 120)",
|
|
Required: false,
|
|
Default: float64(120),
|
|
},
|
|
"workdir": {
|
|
Type: "string",
|
|
Description: "Working directory for the command (default: sandbox default)",
|
|
Required: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *execTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
|
cmdStr, ok := args["command"].(string)
|
|
if !ok || cmdStr == "" {
|
|
return ErrorResult("'command' argument is required and must be a string"), nil
|
|
}
|
|
|
|
// Use a timeout if specified in args
|
|
execCtx := ctx
|
|
if timeoutVal, ok := args["timeout"]; ok {
|
|
if timeout, err := toFloat64(timeoutVal); err == nil && timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
execCtx, cancel = context.WithTimeout(ctx, time.Duration(timeout*float64(time.Second)))
|
|
defer cancel()
|
|
}
|
|
}
|
|
|
|
// Set working directory if specified
|
|
sb := t.sandbox
|
|
if wd, ok := args["workdir"].(string); ok && wd != "" {
|
|
if ps, ok := sb.(*sandbox.ProcessSandbox); ok {
|
|
ps.WorkingDir = wd
|
|
}
|
|
}
|
|
|
|
// Execute the command via shell
|
|
result, err := sb.Execute(execCtx, "sh", "-c", cmdStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exec tool: %w", err)
|
|
}
|
|
|
|
return &Result{
|
|
Success: result.ExitCode == 0,
|
|
Data: map[string]interface{}{
|
|
"stdout": result.Stdout,
|
|
"stderr": result.Stderr,
|
|
"exit_code": result.ExitCode,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// read_file — Read the contents of a file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type readFileTool struct{}
|
|
|
|
func NewReadFileTool() Tool { return &readFileTool{} }
|
|
|
|
func (t *readFileTool) Name() string { return "read_file" }
|
|
|
|
func (t *readFileTool) Description() string {
|
|
return "Read the contents of a file from the local filesystem. Returns the file content as a string."
|
|
}
|
|
|
|
func (t *readFileTool) Parameters() map[string]ParameterSchema {
|
|
return map[string]ParameterSchema{
|
|
"path": {
|
|
Type: "string",
|
|
Description: "Absolute path to the file to read",
|
|
Required: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *readFileTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok || path == "" {
|
|
return ErrorResult("'path' argument is required and must be a string"), nil
|
|
}
|
|
|
|
// Prevent directory traversal / read of non-regular files
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("cannot access %q: %v", path, err)), nil
|
|
}
|
|
if info.IsDir() {
|
|
return ErrorResult(fmt.Sprintf("%q is a directory, not a file", path)), nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read %q: %v", path, err)), nil
|
|
}
|
|
|
|
return SuccessResult(map[string]interface{}{
|
|
"path": path,
|
|
"content": string(data),
|
|
"size": len(data),
|
|
}), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// write_file — Write content to a file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type writeFileTool struct{}
|
|
|
|
func NewWriteFileTool() Tool { return &writeFileTool{} }
|
|
|
|
func (t *writeFileTool) Name() string { return "write_file" }
|
|
|
|
func (t *writeFileTool) Description() string {
|
|
return "Write content to a file on the local filesystem. Creates parent directories if needed."
|
|
}
|
|
|
|
func (t *writeFileTool) Parameters() map[string]ParameterSchema {
|
|
return map[string]ParameterSchema{
|
|
"path": {
|
|
Type: "string",
|
|
Description: "Absolute path where the file should be written",
|
|
Required: true,
|
|
},
|
|
"content": {
|
|
Type: "string",
|
|
Description: "The content to write to the file",
|
|
Required: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *writeFileTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok || path == "" {
|
|
return ErrorResult("'path' argument is required and must be a string"), nil
|
|
}
|
|
|
|
content, ok := args["content"].(string)
|
|
if !ok {
|
|
return ErrorResult("'content' argument is required and must be a string"), nil
|
|
}
|
|
|
|
// Create parent directories
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to create directories for %q: %v", path, err)), nil
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write %q: %v", path, err)), nil
|
|
}
|
|
|
|
return SuccessResult(map[string]interface{}{
|
|
"path": path,
|
|
"size": len(content),
|
|
}), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// list_dir — List the contents of a directory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type listDirTool struct{}
|
|
|
|
func NewListDirTool() Tool { return &listDirTool{} }
|
|
|
|
func (t *listDirTool) Name() string { return "list_dir" }
|
|
|
|
func (t *listDirTool) Description() string {
|
|
return "List files and directories in a given path. Returns names, sizes, and modification times."
|
|
}
|
|
|
|
func (t *listDirTool) Parameters() map[string]ParameterSchema {
|
|
return map[string]ParameterSchema{
|
|
"path": {
|
|
Type: "string",
|
|
Description: "Absolute path to the directory to list",
|
|
Required: true,
|
|
},
|
|
"recursive": {
|
|
Type: "boolean",
|
|
Description: "Whether to list recursively (default: false)",
|
|
Required: false,
|
|
Default: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *listDirTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok || path == "" {
|
|
return ErrorResult("'path' argument is required and must be a string"), nil
|
|
}
|
|
|
|
recursive, _ := args["recursive"].(bool)
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("cannot access %q: %v", path, err)), nil
|
|
}
|
|
if !info.IsDir() {
|
|
return ErrorResult(fmt.Sprintf("%q is not a directory", path)), nil
|
|
}
|
|
|
|
var entries []map[string]interface{}
|
|
|
|
if recursive {
|
|
err = filepath.Walk(path, func(p string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(path, p)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
entries = append(entries, entryToMap(p, rel, fi))
|
|
return nil
|
|
})
|
|
} else {
|
|
files, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to list %q: %v", path, err)), nil
|
|
}
|
|
for _, f := range files {
|
|
fi, err := f.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fullPath := filepath.Join(path, f.Name())
|
|
entries = append(entries, entryToMap(fullPath, f.Name(), fi))
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to list %q: %v", path, err)), nil
|
|
}
|
|
|
|
return SuccessResult(map[string]interface{}{
|
|
"path": path,
|
|
"entries": entries,
|
|
"count": len(entries),
|
|
}), nil
|
|
}
|
|
|
|
func entryToMap(fullPath, name string, fi os.FileInfo) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"name": name,
|
|
"path": fullPath,
|
|
"size": fi.Size(),
|
|
"is_dir": fi.IsDir(),
|
|
"mode": fi.Mode().String(),
|
|
"modtime": fi.ModTime().Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// search_files — Search for content in files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type searchFilesTool struct{}
|
|
|
|
func NewSearchFilesTool() Tool { return &searchFilesTool{} }
|
|
|
|
func (t *searchFilesTool) Name() string { return "search_files" }
|
|
|
|
func (t *searchFilesTool) Description() string {
|
|
return "Search for a pattern in files within a directory. Supports simple substring matching."
|
|
}
|
|
|
|
func (t *searchFilesTool) Parameters() map[string]ParameterSchema {
|
|
return map[string]ParameterSchema{
|
|
"pattern": {
|
|
Type: "string",
|
|
Description: "The text pattern to search for (substring match)",
|
|
Required: true,
|
|
},
|
|
"path": {
|
|
Type: "string",
|
|
Description: "Directory to search in (default: current directory)",
|
|
Required: false,
|
|
Default: ".",
|
|
},
|
|
"include": {
|
|
Type: "string",
|
|
Description: "File glob pattern to include (e.g., '*.go', '*.{ts,tsx}')",
|
|
Required: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *searchFilesTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
|
pattern, ok := args["pattern"].(string)
|
|
if !ok || pattern == "" {
|
|
return ErrorResult("'pattern' argument is required and must be a string"), nil
|
|
}
|
|
|
|
searchPath := "."
|
|
if p, ok := args["path"].(string); ok && p != "" {
|
|
searchPath = p
|
|
}
|
|
|
|
include, _ := args["include"].(string)
|
|
|
|
// Verify search path exists
|
|
info, err := os.Stat(searchPath)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("cannot access search path %q: %v", searchPath, err)), nil
|
|
}
|
|
if !info.IsDir() {
|
|
return ErrorResult(fmt.Sprintf("%q is not a directory", searchPath)), nil
|
|
}
|
|
|
|
var matches []map[string]interface{}
|
|
|
|
err = filepath.Walk(searchPath, func(p string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil // skip files we can't access
|
|
}
|
|
if fi.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Apply include filter
|
|
if include != "" {
|
|
matched, err := filepath.Match(include, fi.Name())
|
|
if err != nil || !matched {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Read file and search
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return nil // skip unreadable files
|
|
}
|
|
|
|
content := string(data)
|
|
if strings.Contains(content, pattern) {
|
|
matches = append(matches, map[string]interface{}{
|
|
"path": p,
|
|
"size": len(data),
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("search failed: %v", err)), nil
|
|
}
|
|
|
|
return SuccessResult(map[string]interface{}{
|
|
"pattern": pattern,
|
|
"path": searchPath,
|
|
"matches": matches,
|
|
"count": len(matches),
|
|
}), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// toFloat64 converts an interface{} value to float64.
|
|
// Supports float64, int, int64, and json.Number types.
|
|
func toFloat64(v interface{}) (float64, error) {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return val, nil
|
|
case int:
|
|
return float64(val), nil
|
|
case int64:
|
|
return float64(val), nil
|
|
case json.Number:
|
|
return val.Float64()
|
|
default:
|
|
return 0, fmt.Errorf("cannot convert %T to float64", v)
|
|
}
|
|
}
|
|
|
|
// Compile-time interface checks.
|
|
var _ Tool = (*execTool)(nil)
|
|
var _ Tool = (*readFileTool)(nil)
|
|
var _ Tool = (*writeFileTool)(nil)
|
|
var _ Tool = (*listDirTool)(nil)
|
|
var _ Tool = (*searchFilesTool)(nil)
|