tools: replace file and reasoning built-ins with shell scripts
Remove pkg/tools/file and pkg/tools/reasoning. Both are now implemented as execute_tool scripts in ollie/t/ with feature parity: - file_read, file_write, file_edit, file_grep, file_glob - reasoning_think isread guard delegated to /tmp/ollie/$OLLIE_SESSION_ID/<hash> markers. SetSessionID renames the tmpdir on session rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ba68ff35e
commit
abe8ab76cc
|
|
@ -339,6 +339,12 @@ func (s *agentCore) SetSessionID(newID string) error {
|
|||
}
|
||||
}
|
||||
s.sessionID = newID
|
||||
// Rename tmpdir so isread markers remain valid after rename.
|
||||
oldTemp := "/tmp/ollie/" + oldID
|
||||
newTemp := "/tmp/ollie/" + newID
|
||||
if _, err := os.Stat(oldTemp); err == nil {
|
||||
os.Rename(oldTemp, newTemp) //nolint:errcheck
|
||||
}
|
||||
// Propagate to the execute server env.
|
||||
if s.dispatcher != nil {
|
||||
if srv, ok := s.dispatcher.GetServer("execute"); ok {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/aymanbagabas/go-udiff"
|
||||
)
|
||||
|
||||
// unifiedDiff produces a unified diff of oldContent → newContent for display.
|
||||
func unifiedDiff(path, oldContent, newContent string) string {
|
||||
return udiff.Unified(path, path, oldContent, newContent)
|
||||
}
|
||||
|
||||
// plusLines formats content as diff "added" lines, each prefixed with "+".
|
||||
// Used by file_write for new files.
|
||||
func plusLines(content string) string {
|
||||
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
|
||||
sb := strings.Builder{}
|
||||
for _, l := range lines {
|
||||
sb.WriteByte('+')
|
||||
sb.WriteString(l)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolEdit = tools.ToolInfo{
|
||||
Name: "file_edit",
|
||||
Description: `Perform exact string replacement in a file.
|
||||
|
||||
Usage:
|
||||
- File path must be absolute (not relative)
|
||||
- Must read file with file_read before editing
|
||||
- old_string must be unique in file (or use replace_all)
|
||||
- Preserve exact indentation from file_read output
|
||||
- old_string and new_string must be different
|
||||
|
||||
Parameters:
|
||||
- replace_all: replace all occurrences (for renaming)
|
||||
- old_string: text to find (exact match)
|
||||
- new_string: replacement text
|
||||
|
||||
Notes:
|
||||
- file_read returns diff format: exclude leading "+" from old_string
|
||||
- For non-unique old_string: use larger context or replace_all
|
||||
- Prefer over file_write for modifications
|
||||
- Line endings preserved`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path", "old_string", "new_string"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to modify"},
|
||||
"old_string": {"type": "string", "description": "The text to replace"},
|
||||
"new_string": {"type": "string", "description": "The text to replace it with (must be different from old_string)"},
|
||||
"replace_all": {"type": "boolean", "description": "Replace all occurences of old_string (default false)"}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchEdit(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
OldString string `json:"old_string"`
|
||||
NewString string `json:"new_string"`
|
||||
ReplaceAll bool `json:"replace_all"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
if err := s.checkAccess(a.FilePath, true); err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
if a.OldString == a.NewString {
|
||||
return errText("old_string and new_string must be different"), nil
|
||||
}
|
||||
if !s.wasRead(a.FilePath) {
|
||||
return errText("file must be read first. Use the Read tool to examine the file contents."), nil
|
||||
}
|
||||
|
||||
original, err := readFileChecked(a.FilePath)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
|
||||
newContent, err := robustReplace(original, a.OldString, a.NewString, a.ReplaceAll)
|
||||
if err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.FilePath, []byte(newContent), 0644); err != nil { //nolint:gosec
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
return unifiedDiff(a.FilePath, original, newContent), nil
|
||||
}
|
||||
|
||||
// robustReplace tries exact → whitespace-normalized → indentation-flexible → trimmed-boundary.
|
||||
func robustReplace(content, oldString, newString string, replaceAll bool) (string, error) {
|
||||
if m := findExact(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findWSNormalized(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findIndentFlexible(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findTrimmedBoundary(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
return "", fmt.Errorf("old_string not found in file")
|
||||
}
|
||||
|
||||
func doReplace(content, match, newString string, replaceAll bool) (string, error) {
|
||||
if replaceAll {
|
||||
return strings.ReplaceAll(content, match, newString), nil
|
||||
}
|
||||
n := strings.Count(content, match)
|
||||
if n == 0 {
|
||||
return "", fmt.Errorf("match disappeared from content")
|
||||
}
|
||||
if n > 1 {
|
||||
return "", fmt.Errorf("old_string is not unique (%d occurrences). Use replace_all=true or provide more context", n)
|
||||
}
|
||||
return strings.Replace(content, match, newString, 1), nil
|
||||
}
|
||||
|
||||
func findExact(content, find string) string {
|
||||
if strings.Contains(content, find) {
|
||||
return find
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findWSNormalized(content, find string) string {
|
||||
nf := strings.Join(strings.Fields(find), " ")
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if strings.Join(strings.Fields(block), " ") == nf {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findIndentFlexible(content, find string) string {
|
||||
deindent := func(text string) string {
|
||||
ls := strings.Split(text, "\n")
|
||||
min := 10000
|
||||
for _, l := range ls {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
continue
|
||||
}
|
||||
for i, r := range l {
|
||||
if r != ' ' && r != '\t' {
|
||||
if i < min {
|
||||
min = i
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if min == 10000 {
|
||||
return text
|
||||
}
|
||||
out := make([]string, len(ls))
|
||||
for i, l := range ls {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
out[i] = l
|
||||
} else if len(l) > min {
|
||||
out[i] = l[min:]
|
||||
} else {
|
||||
out[i] = strings.TrimLeft(l, " \t")
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
nf := deindent(find)
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if deindent(block) == nf {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findTrimmedBoundary(content, find string) string {
|
||||
trimmed := strings.TrimSpace(find)
|
||||
if trimmed == find {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(content, trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if strings.TrimSpace(block) == trimmed {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolGlob = tools.ToolInfo{
|
||||
Name: "file_glob",
|
||||
Description: `Find files by name/path patterns.
|
||||
|
||||
Usage:
|
||||
- Fast pattern matching for any codebase size
|
||||
- Results sorted by modification time (newest first)
|
||||
- Respects .gitignore and .ignore files by default
|
||||
- Use instead of find or ls commands
|
||||
|
||||
Patterns:
|
||||
- **/*.js: all JavaScript files recursively
|
||||
- src/**/*.ts: TypeScript files in src/ and subdirectories
|
||||
- *.go: Go files in current directory
|
||||
- **/test_*.py: Python test files recursively
|
||||
|
||||
Notes:
|
||||
- Returns absolute file paths
|
||||
- Use for discovering project structure
|
||||
- Combine with file_grep for content search`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["pattern"],
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.js', 'src/**/*.ts'"},
|
||||
"path": {"type": "string", "description": "Directory to search in. Omit for current working directory. Do NOT enter 'undefined' or 'null'."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchGlob(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(a.Pattern) == "" {
|
||||
return "", fmt.Errorf("pattern is required")
|
||||
}
|
||||
if s.rgPath == "" {
|
||||
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
|
||||
}
|
||||
|
||||
searchPath := a.Path
|
||||
if searchPath == "" {
|
||||
searchPath = s.projectDir
|
||||
}
|
||||
searchPath = filepath.Clean(searchPath)
|
||||
|
||||
if err := s.checkAccess(searchPath, false); err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
if err := ensureSearchPath(searchPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.rgPath, "--no-config", "--files", "-g", a.Pattern, searchPath)
|
||||
cmd.Dir = s.projectDir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return "", nil
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
lines := splitLines(stdout.String())
|
||||
entries := make([]entry, 0, len(lines))
|
||||
for _, p := range lines {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{p, info.ModTime()})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].modTime.Equal(entries[j].modTime) {
|
||||
return entries[i].path < entries[j].path
|
||||
}
|
||||
return entries[i].modTime.After(entries[j].modTime)
|
||||
})
|
||||
|
||||
paths := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
paths[i] = e.path
|
||||
}
|
||||
return strings.Join(paths, "\n"), nil
|
||||
}
|
||||
|
||||
// statPath wraps os.Stat for use by grep.go and glob.go.
|
||||
func statPath(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolGrep = tools.ToolInfo{
|
||||
Name: "file_grep",
|
||||
Description: `Search file contents using ripgrep.
|
||||
|
||||
Usage:
|
||||
- Primary content search tool (use instead of grep in execute_code)
|
||||
- Respects .gitignore and .ignore files by default
|
||||
- Default output: file paths only (files_with_matches)
|
||||
- Use output_mode: 'content' for matching lines with context
|
||||
- Use output_mode: 'count' for match counts only
|
||||
|
||||
Syntax:
|
||||
- Uses ripgrep regex syntax (not GNU grep)
|
||||
- Escape literal braces: \{ and \}
|
||||
- multiline: true for patterns spanning lines
|
||||
- Case insensitive: -i=true
|
||||
|
||||
Examples:
|
||||
- Find TODOs: pattern='TODO', output_mode='content'
|
||||
- Search specific files: glob='*.go', pattern='func main'
|
||||
- With context: pattern='error', context=3, output_mode='content'`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["pattern"],
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern to search for (ripgrep syntax)"},
|
||||
"path": {"type": "string", "description": "File or directory to search in. Defaults to cwd."},
|
||||
"output_mode": {"type": "string", "description": "Default: 'files_with_matches'. 'content' shows matching lines, 'count' shows match counts.", "enum": ["content", "files_with_matches", "count"]},
|
||||
"glob": {"type": "string", "description": "Glob filter, e.g. '*.js', '*.{ts,tsx}'"},
|
||||
"type": {"type": "string", "description": "File type filter, e.g. 'js', 'py', 'rust'. More efficient than glob for standard types."},
|
||||
"-i": {"type": "boolean", "description": "Case insensitive search"},
|
||||
"-n": {"type": "boolean", "description": "Show line numbers (default true). Requires output_mode: 'content'."},
|
||||
"-A": {"type": "number", "description": "Lines after match"},
|
||||
"-B": {"type": "number", "description": "Lines before match"},
|
||||
"-C": {"type": "number", "description": "Lines before and after match (short flag alias)"},
|
||||
"context": {"type": "number", "description": "Lines before and after match"},
|
||||
"multiline": {"type": "boolean", "description": "Enable multiline mode where . matches newlines. Default: false."},
|
||||
"head_limit": {"type": "number", "description": "Limit output to first N lines/entries (like | head -N). Default: 0 (unlimited)."},
|
||||
"offset": {"type": "number", "description": "Skip first N entries before applying head_limit (like | tail -n +N | head -N). Default: 0."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchGrep(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
OutputMode string `json:"output_mode"`
|
||||
Glob string `json:"glob"`
|
||||
Type string `json:"type"`
|
||||
IgnoreCase bool `json:"-i"`
|
||||
LineNumber *bool `json:"-n"`
|
||||
After *int `json:"-A"`
|
||||
Before *int `json:"-B"`
|
||||
ContextC *int `json:"-C"`
|
||||
Context *int `json:"context"`
|
||||
Multiline bool `json:"multiline"`
|
||||
HeadLimit *int `json:"head_limit"`
|
||||
Offset *int `json:"offset"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(a.Pattern) == "" {
|
||||
return "", fmt.Errorf("pattern is required")
|
||||
}
|
||||
if s.rgPath == "" {
|
||||
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
|
||||
}
|
||||
|
||||
outputMode := a.OutputMode
|
||||
if outputMode == "" {
|
||||
outputMode = "files_with_matches"
|
||||
}
|
||||
|
||||
searchPath := a.Path
|
||||
if searchPath == "" {
|
||||
searchPath = s.projectDir
|
||||
}
|
||||
searchPath = filepath.Clean(searchPath)
|
||||
|
||||
if err := s.checkAccess(searchPath, false); err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
if err := ensureSearchPath(searchPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmdArgs := []string{"--no-config", "--color=never"}
|
||||
switch outputMode {
|
||||
case "files_with_matches":
|
||||
cmdArgs = append(cmdArgs, "-l")
|
||||
case "count":
|
||||
cmdArgs = append(cmdArgs, "--count")
|
||||
case "content":
|
||||
showLineNums := true
|
||||
if a.LineNumber != nil {
|
||||
showLineNums = *a.LineNumber
|
||||
}
|
||||
if showLineNums {
|
||||
cmdArgs = append(cmdArgs, "-n")
|
||||
}
|
||||
}
|
||||
if a.IgnoreCase {
|
||||
cmdArgs = append(cmdArgs, "-i")
|
||||
}
|
||||
if a.Multiline {
|
||||
cmdArgs = append(cmdArgs, "-U", "--multiline-dotall")
|
||||
}
|
||||
if a.Glob != "" {
|
||||
cmdArgs = append(cmdArgs, "-g", a.Glob)
|
||||
}
|
||||
if a.Type != "" {
|
||||
cmdArgs = append(cmdArgs, "-t", a.Type)
|
||||
}
|
||||
ctx_ := a.Context
|
||||
if ctx_ == nil {
|
||||
ctx_ = a.ContextC
|
||||
}
|
||||
if ctx_ != nil {
|
||||
cmdArgs = append(cmdArgs, "-C", fmt.Sprintf("%d", *ctx_))
|
||||
}
|
||||
if a.Before != nil {
|
||||
cmdArgs = append(cmdArgs, "-B", fmt.Sprintf("%d", *a.Before))
|
||||
}
|
||||
if a.After != nil {
|
||||
cmdArgs = append(cmdArgs, "-A", fmt.Sprintf("%d", *a.After))
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-e", a.Pattern, searchPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.rgPath, cmdArgs...)
|
||||
cmd.Dir = s.projectDir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return "", nil // no matches
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := splitLines(stdout.String())
|
||||
lines = paginate(lines, intOrZero(a.Offset), intOrZero(a.HeadLimit))
|
||||
|
||||
if outputMode == "content" {
|
||||
lines = formatContentLines(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func formatContentLines(lines []string) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == "--" {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
if p, rest, ok := strings.Cut(line, ":"); ok {
|
||||
if ln, content, ok2 := strings.Cut(rest, ":"); ok2 && isDigits(ln) {
|
||||
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if p, rest, ok := strings.Cut(line, "-"); ok {
|
||||
if ln, content, ok2 := strings.Cut(rest, "-"); ok2 && isDigits(ln) {
|
||||
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureSearchPath(path string) error {
|
||||
info, err := statPath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error accessing path: %w", err)
|
||||
}
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a searchable path: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolRead = tools.ToolInfo{
|
||||
Name: "file_read",
|
||||
Description: `Read a file from the local filesystem.
|
||||
|
||||
Usage:
|
||||
- File path must be absolute (not relative)
|
||||
- Reads first 2000 lines by default
|
||||
- Lines longer than 2000 characters are truncated
|
||||
- Maximum file size: 8 MB
|
||||
- Returns content in diff format (existing lines have no prefix)
|
||||
- Can read any file on the system
|
||||
- Reading a non-existent file returns an error
|
||||
- Reading an empty file returns "(empty file)"
|
||||
|
||||
Notes:
|
||||
- Use offset/limit parameters for large files
|
||||
- Read directories with execute_code, not this tool
|
||||
- Call multiple file_read operations in parallel when exploring
|
||||
- Always read a file before editing or writing to it with file_edit/file_write`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to read"},
|
||||
"offset": {"type": "number", "description": "The line number to start reading from. Only provide if the file is too large to read at once"},
|
||||
"limit": {"type": "number", "description": "The number of lines to read. Only provide if the file is too large to read at once."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
const (
|
||||
maxFileSize = 8 * 1024 * 1024 // 8 MB
|
||||
defaultLimit = 2000
|
||||
maxLineLen = 2000
|
||||
)
|
||||
|
||||
func (s *Server) dispatchRead(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Offset *int `json:"offset"`
|
||||
Limit *int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
if err := s.checkAccess(a.FilePath, false); err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
file, err := openChecked(a.FilePath)
|
||||
if err != nil {
|
||||
return err.Error(), nil // surface as text
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
off := 1
|
||||
if a.Offset != nil && *a.Offset > 1 {
|
||||
off = *a.Offset
|
||||
}
|
||||
lim := defaultLimit
|
||||
if a.Limit != nil && *a.Limit > 0 {
|
||||
lim = *a.Limit
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var lines []string
|
||||
lineNum := 1
|
||||
for scanner.Scan() {
|
||||
if lineNum >= off && len(lines) < lim {
|
||||
line := scanner.Text()
|
||||
if len(line) > maxLineLen {
|
||||
line = line[:maxLineLen] + "..."
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lineNum++
|
||||
if lineNum > off+lim {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "(empty file)", nil
|
||||
}
|
||||
|
||||
s.markRead(a.FilePath)
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
|
||||
// openChecked validates and opens a regular file within size limits.
|
||||
func openChecked(path string) (*os.File, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, fmt.Errorf("error: path must be absolute, got: %s", path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error accessing file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("error: not a regular file: %s", path)
|
||||
}
|
||||
if info.Size() > maxFileSize {
|
||||
return nil, fmt.Errorf("error: file too large (%d bytes, max %d)", info.Size(), maxFileSize)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening file: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readFileChecked reads the full content of a regular file within size limits.
|
||||
func readFileChecked(path string) (string, error) {
|
||||
f, err := openChecked(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
buf := make([]byte, info.Size())
|
||||
n, err := f.Read(buf)
|
||||
if err != nil && n == 0 {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
// Package file implements the Read, Write, Edit, Grep, and Glob tools.
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"ollie/internal/sandbox"
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
// Server implements tools.Server for file operations.
|
||||
type Server struct {
|
||||
projectDir string
|
||||
rgPath string
|
||||
|
||||
mu sync.Mutex
|
||||
readFiles map[string]struct{}
|
||||
}
|
||||
|
||||
// New creates a Server for the given project directory.
|
||||
func New(projectDir string) *Server {
|
||||
rg, _ := exec.LookPath("rg")
|
||||
return &Server{
|
||||
projectDir: projectDir,
|
||||
rgPath: rg,
|
||||
readFiles: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Decl returns a factory for a file Server with the given project directory.
|
||||
func Decl(projectDir string) func() tools.Server {
|
||||
return func() tools.Server { return New(projectDir) }
|
||||
}
|
||||
|
||||
// SetCWD implements tools.CWDSetter.
|
||||
func (s *Server) SetCWD(dir string) {
|
||||
s.mu.Lock()
|
||||
s.projectDir = dir
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ListTools implements tools.Server.
|
||||
func (s *Server) ListTools() ([]tools.ToolInfo, error) {
|
||||
return []tools.ToolInfo{ToolRead, ToolWrite, ToolEdit, ToolGrep, ToolGlob}, nil
|
||||
}
|
||||
|
||||
// CallTool implements tools.Server.
|
||||
func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||
var (
|
||||
text string
|
||||
err error
|
||||
)
|
||||
switch tool {
|
||||
case "file_read":
|
||||
text, err = s.dispatchRead(ctx, args)
|
||||
case "file_write":
|
||||
text, err = s.dispatchWrite(ctx, args)
|
||||
case "file_edit":
|
||||
text, err = s.dispatchEdit(ctx, args)
|
||||
case "file_grep":
|
||||
text, err = s.dispatchGrep(ctx, args)
|
||||
case "file_glob":
|
||||
text, err = s.dispatchGlob(ctx, args)
|
||||
default:
|
||||
text = "unknown tool: " + tool
|
||||
}
|
||||
if err != nil {
|
||||
text = "error: " + err.Error()
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"content": []map[string]string{{"type": "text", "text": text}},
|
||||
})
|
||||
}
|
||||
|
||||
// Close implements tools.Server (no-op).
|
||||
func (s *Server) Close() {}
|
||||
|
||||
func (s *Server) markRead(path string) {
|
||||
s.mu.Lock()
|
||||
s.readFiles[path] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) wasRead(path string) bool {
|
||||
s.mu.Lock()
|
||||
_, ok := s.readFiles[path]
|
||||
s.mu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkAccess verifies that path is allowed by the sandbox config.
|
||||
// write indicates whether write access is needed.
|
||||
func (s *Server) checkAccess(path string, write bool) error {
|
||||
cfg, err := sandbox.LoadMerged("default")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sandbox config: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
cwd := s.projectDir
|
||||
s.mu.Unlock()
|
||||
return sandbox.CheckPath(cfg, path, write, cwd)
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var _ tools.Server = (*Server)(nil)
|
||||
var _ tools.CWDSetter = (*Server)(nil)
|
||||
|
||||
// Shared argument helpers.
|
||||
|
||||
func intOrZero(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func paginate(items []string, offset, headLimit int) []string {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(items) {
|
||||
return nil
|
||||
}
|
||||
items = items[offset:]
|
||||
if headLimit > 0 && headLimit < len(items) {
|
||||
items = items[:headLimit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
s = s[:len(s)-countTrailingNewlines(s)]
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
lines := make([]string, 0, 32)
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func countTrailingNewlines(s string) int {
|
||||
n := 0
|
||||
for i := len(s) - 1; i >= 0 && s[i] == '\n'; i-- {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// errText wraps an error message as a non-error tool result (model sees error text).
|
||||
func errText(format string, args ...any) string {
|
||||
return "error: " + fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolWrite = tools.ToolInfo{
|
||||
Name: "file_write",
|
||||
Description: `Write or overwrite a file.
|
||||
|
||||
Usage:
|
||||
- Creates new file or overwrites existing file
|
||||
- File path must be absolute (not relative)
|
||||
- For existing files: must read with file_read first
|
||||
- Overwrites entire file content
|
||||
|
||||
Notes:
|
||||
- Prefer file_edit for modifying existing files
|
||||
- Only create new files when explicitly required
|
||||
- Write permissions required for target location
|
||||
- Creates parent directories if needed`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path", "content"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)"},
|
||||
"content": {"type": "string", "description": "The content to write to the file"}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchWrite(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
if err := s.checkAccess(a.FilePath, true); err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(a.FilePath)
|
||||
exists := statErr == nil
|
||||
if exists && !s.wasRead(a.FilePath) {
|
||||
return errText("existing file must be read first. Use the Read tool to examine the file contents."), nil
|
||||
}
|
||||
|
||||
var oldContent string
|
||||
if exists {
|
||||
oldContent, _ = readFileChecked(a.FilePath)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.FilePath, []byte(a.Content), 0644); err != nil { //nolint:gosec
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
s.markRead(a.FilePath)
|
||||
|
||||
if !exists {
|
||||
return plusLines(a.Content), nil
|
||||
}
|
||||
return unifiedDiff(a.FilePath, oldContent, a.Content), nil
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package reasoning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
// Decl returns a factory for a reasoning Server.
|
||||
func Decl() func() tools.Server { return func() tools.Server { return &Server{} } }
|
||||
|
||||
// Server implements tools.Server for reasoning_think.
|
||||
type Server struct{}
|
||||
|
||||
// ListTools implements tools.Server.
|
||||
func (s *Server) ListTools() ([]tools.ToolInfo, error) {
|
||||
return []tools.ToolInfo{ToolThink}, nil
|
||||
}
|
||||
|
||||
// CallTool implements tools.Server.
|
||||
func (s *Server) CallTool(_ context.Context, tool string, _ json.RawMessage) (json.RawMessage, error) {
|
||||
var text string
|
||||
switch tool {
|
||||
case ToolThink.Name:
|
||||
// No-op: the thought is recorded in conversation history by the loop.
|
||||
default:
|
||||
text = "error: unknown tool: " + tool
|
||||
}
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"content": []map[string]string{{"type": "text", "text": text}},
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Close implements tools.Server (no-op).
|
||||
func (s *Server) Close() {}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package reasoning
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
// think is a no-op tool for reasoning models. it functions like an in-context "scratchpad".
|
||||
|
||||
var ToolThink = tools.ToolInfo{
|
||||
Name: "reasoning_think",
|
||||
Description: `Internal reasoning scratchpad (thoughts not shown to user).
|
||||
|
||||
Usage:
|
||||
- Break down complex problems
|
||||
- Analyze constraints and requirements
|
||||
- Reason through multi-step solutions
|
||||
- Plan approaches before acting
|
||||
- Work through logic and edge cases
|
||||
|
||||
Notes:
|
||||
- Thought content is internal only (not visible to user)
|
||||
- Use for local reasoning during execution
|
||||
- Use reasoning_plan for structured multi-step planning
|
||||
- Keep thoughts focused and actionable
|
||||
|
||||
Examples:
|
||||
- Analyzing code structure before modification
|
||||
- Considering alternative approaches
|
||||
- Working through error scenarios
|
||||
- Validating assumptions before acting`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["thought"],
|
||||
"properties": {
|
||||
"thought": {"type": "string", "description": "A reflective note or intermediate reasoning step."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
Reference in New Issue