diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 6a9cfcd..01e9cf5 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -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 { diff --git a/pkg/tools/file/diff.go b/pkg/tools/file/diff.go deleted file mode 100644 index 545124e..0000000 --- a/pkg/tools/file/diff.go +++ /dev/null @@ -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() -} diff --git a/pkg/tools/file/edit.go b/pkg/tools/file/edit.go deleted file mode 100644 index b23cbdf..0000000 --- a/pkg/tools/file/edit.go +++ /dev/null @@ -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 "" -} diff --git a/pkg/tools/file/glob.go b/pkg/tools/file/glob.go deleted file mode 100644 index 363fbb4..0000000 --- a/pkg/tools/file/glob.go +++ /dev/null @@ -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) -} diff --git a/pkg/tools/file/grep.go b/pkg/tools/file/grep.go deleted file mode 100644 index 0a80b9f..0000000 --- a/pkg/tools/file/grep.go +++ /dev/null @@ -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 -} diff --git a/pkg/tools/file/read.go b/pkg/tools/file/read.go deleted file mode 100644 index 34d2712..0000000 --- a/pkg/tools/file/read.go +++ /dev/null @@ -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 -} diff --git a/pkg/tools/file/server.go b/pkg/tools/file/server.go deleted file mode 100644 index 8510d6d..0000000 --- a/pkg/tools/file/server.go +++ /dev/null @@ -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...) -} diff --git a/pkg/tools/file/write.go b/pkg/tools/file/write.go deleted file mode 100644 index fed27f9..0000000 --- a/pkg/tools/file/write.go +++ /dev/null @@ -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 -} diff --git a/pkg/tools/reasoning/server.go b/pkg/tools/reasoning/server.go deleted file mode 100644 index 80bfc11..0000000 --- a/pkg/tools/reasoning/server.go +++ /dev/null @@ -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() {} diff --git a/pkg/tools/reasoning/think.go b/pkg/tools/reasoning/think.go deleted file mode 100644 index 4111e14..0000000 --- a/pkg/tools/reasoning/think.go +++ /dev/null @@ -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."} - } - }`), -}