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:
Levi Neely 2026-04-14 15:26:12 +02:00
parent 9ba68ff35e
commit abe8ab76cc
10 changed files with 6 additions and 1030 deletions

View File

@ -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 {

View File

@ -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()
}

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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...)
}

View File

@ -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
}

View File

@ -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() {}

View File

@ -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."}
}
}`),
}