orca.ai/pkg/tool/builtin.go
大森 6b94476347 Initial commit: Orca Agent Framework
Core features:
- Microkernel architecture with Actor model
- Session management with JSONL persistence
- Tool system (5 built-in tools)
- Skill system with SKILL.md parsing
- Sandbox security execution
- Ollama integration with gemma4:e4b
- Prompt-based tool calling (compatible with native function calling)
- REPL interface

11 packages, all tests passing
2026-05-08 00:55:48 +08:00

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: 30)",
Required: false,
Default: float64(30),
},
"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)