github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/fs.go (about)

     1  package agenttoolset
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"math"
    10  	"os"
    11  	"path/filepath"
    12  	"strings"
    13  
    14  	anthropic "github.com/anthropics/anthropic-sdk-go"
    15  )
    16  
    17  // defaultMaxFileBytes is the read/edit size cap used when
    18  // AgentToolContext.MaxFileBytes is unset (zero).
    19  const defaultMaxFileBytes = 256 * 1024
    20  
    21  // readChunkBytes is how much readRangeStreaming reads from the file at a time.
    22  const readChunkBytes = 64 * 1024
    23  
    24  // resolveMaxBytes turns a configured cap into an effective size limit. Zero
    25  // selects def (the built-in default); a negative value disables the size check
    26  // entirely (capped == false). It governs only the size guard — callers still
    27  // reject non-regular files, since the FIFO/device hang hazard is unrelated to
    28  // memory headroom.
    29  func resolveMaxBytes(configured, def int64) (limit int64, capped bool) {
    30  	switch {
    31  	case configured < 0:
    32  		return 0, false
    33  	case configured == 0:
    34  		return def, true
    35  	default:
    36  		return configured, true
    37  	}
    38  }
    39  
    40  // BetaReadTool returns an anthropic.BetaTool that reads file contents under
    41  // env.Workdir or an allowed root.
    42  func BetaReadTool(env *AgentToolContext) anthropic.BetaTool {
    43  	return &funcTool{
    44  		name:        "read",
    45  		description: "Read a UTF-8 text file rooted at the workdir.",
    46  		schema: objectSchema(map[string]any{
    47  			"file_path": prop("string", "Path of the file to read, rooted at the workdir (absolute paths inside it are allowed)."),
    48  			"view_range": map[string]any{
    49  				"type":        "array",
    50  				"items":       map[string]any{"type": "integer"},
    51  				"description": "[start_line, end_line] 1-indexed inclusive",
    52  			},
    53  		}, "file_path"),
    54  		env: env,
    55  		run: execRead,
    56  	}
    57  }
    58  
    59  // BetaWriteTool returns an anthropic.BetaTool that writes file contents under
    60  // env.Workdir or an allowed root, creating parent directories as needed.
    61  func BetaWriteTool(env *AgentToolContext) anthropic.BetaTool {
    62  	return &funcTool{
    63  		name:        "write",
    64  		description: "Write a UTF-8 text file rooted at the workdir, creating parent directories as needed.",
    65  		schema: objectSchema(map[string]any{
    66  			"file_path": prop("string", "Path of the file to write, rooted at the workdir (absolute paths inside it are allowed)."),
    67  			"content":   prop("string", "Full file contents to write."),
    68  		}, "file_path", "content"),
    69  		env: env,
    70  		run: execWrite,
    71  	}
    72  }
    73  
    74  // BetaEditTool returns an anthropic.BetaTool that performs unique-match string
    75  // replacement in a file under env.Workdir or an allowed root.
    76  func BetaEditTool(env *AgentToolContext) anthropic.BetaTool {
    77  	return &funcTool{
    78  		name:        "edit",
    79  		description: "Replace a unique occurrence of old_string with new_string in a file (set replace_all to replace every occurrence).",
    80  		schema: objectSchema(map[string]any{
    81  			"file_path":   prop("string", "Path of the file to edit, rooted at the workdir (absolute paths inside it are allowed)."),
    82  			"old_string":  prop("string", "Substring to find and replace."),
    83  			"new_string":  prop("string", "Replacement text."),
    84  			"replace_all": prop("boolean", "Replace every occurrence instead of requiring a unique match."),
    85  		}, "file_path", "old_string", "new_string"),
    86  		env: env,
    87  		run: execEdit,
    88  	}
    89  }
    90  
    91  func execRead(_ context.Context, raw json.RawMessage, env *AgentToolContext) (string, bool) {
    92  	var in anthropic.BetaManagedAgentsAgentToolset20260401ReadInput
    93  	if err := json.Unmarshal(raw, &in); err != nil {
    94  		return errorf("invalid read input: %v", err)
    95  	}
    96  	if in.FilePath == "" {
    97  		return errorf("read: file_path is required")
    98  	}
    99  	path, err := resolvePath(env, in.FilePath)
   100  	if err != nil {
   101  		return errorf("read: %v", err)
   102  	}
   103  	if len(in.ViewRange) != 0 && len(in.ViewRange) != 2 {
   104  		return errorf("read: view_range must be [start_line, end_line]")
   105  	}
   106  	// Stat before any open: the size cap stops a multi-GB file from OOM'ing
   107  	// the runner, and the mode check rejects FIFOs/devices/dirs before
   108  	// open() can block on them.
   109  	info, err := os.Stat(path)
   110  	if err != nil {
   111  		return errorf("read %s: %s", in.FilePath, fsErrorMessage(err))
   112  	}
   113  	if !info.Mode().IsRegular() {
   114  		return errorf("read: %s is not a regular file", in.FilePath)
   115  	}
   116  	if limit, capped := resolveMaxBytes(env.MaxFileBytes, defaultMaxFileBytes); capped && info.Size() > limit {
   117  		if len(in.ViewRange) == 0 {
   118  			return errorf("read: %s is %d bytes, exceeds %d-byte limit. Use the view_range parameter to read specific line ranges, e.g. view_range: [1, 500].",
   119  				in.FilePath, info.Size(), limit)
   120  		}
   121  		return readRangeStreaming(path, in.FilePath, in.ViewRange[0], in.ViewRange[1], limit)
   122  	}
   123  	data, err := os.ReadFile(path)
   124  	if err != nil {
   125  		return errorf("read %s: %s", in.FilePath, fsErrorMessage(err))
   126  	}
   127  	if len(in.ViewRange) == 0 {
   128  		return string(data), false
   129  	}
   130  	lines := strings.Split(string(data), "\n")
   131  	start := max(0, int(in.ViewRange[0])-1)
   132  	if start >= len(lines) {
   133  		return "", false
   134  	}
   135  	end := len(lines)
   136  	if endLine := int(in.ViewRange[1]); endLine > 0 && endLine < end {
   137  		end = endLine
   138  	}
   139  	// An inverted range selects nothing; without this lines[start:end] panics.
   140  	if end < start {
   141  		return "", false
   142  	}
   143  	return strings.Join(lines[start:end], "\n"), false
   144  }
   145  
   146  // readRangeStreaming returns lines [startLine, endLine] of the file at path,
   147  // capping the selected bytes at limit.
   148  func readRangeStreaming(path, filePath string, startLine, endLine, limit int64) (string, bool) {
   149  	lines := newLineRangeCollector(filePath, startLine, endLine, limit)
   150  	if lines.rangeIsEmpty() {
   151  		return "", false
   152  	}
   153  	f, err := os.Open(path)
   154  	if err != nil {
   155  		return errorf("read %s: %s", filePath, fsErrorMessage(err))
   156  	}
   157  	defer f.Close()
   158  	// Raw byte chunks rather than bufio.Scanner or ReadString: a single huge
   159  	// line must never be buffered whole, so memory stays bounded by limit plus
   160  	// one chunk.
   161  	chunk := make([]byte, readChunkBytes)
   162  	for {
   163  		n, readErr := f.Read(chunk)
   164  		if err := lines.collectFrom(chunk[:n]); err != nil {
   165  			return err.Error(), true
   166  		}
   167  		if readErr == io.EOF {
   168  			break
   169  		}
   170  		if readErr != nil {
   171  			return errorf("read %s: %s", filePath, fsErrorMessage(readErr))
   172  		}
   173  		if lines.rangeIsCollected() {
   174  			break
   175  		}
   176  	}
   177  	return lines.text(), false
   178  }
   179  
   180  // lineRangeCollector collects the bytes of lines [startLine, endLine] from
   181  // consecutive file chunks, capped at limit.
   182  type lineRangeCollector struct {
   183  	filePath           string
   184  	startLine, endLine int64
   185  	start, end         int
   186  	limit              int64
   187  	line               int
   188  	collected          []byte
   189  }
   190  
   191  func newLineRangeCollector(filePath string, startLine, endLine, limit int64) *lineRangeCollector {
   192  	end := math.MaxInt
   193  	if endLine > 0 {
   194  		end = int(endLine)
   195  	}
   196  	return &lineRangeCollector{
   197  		filePath:  filePath,
   198  		startLine: startLine,
   199  		endLine:   endLine,
   200  		start:     max(0, int(startLine)-1),
   201  		end:       end,
   202  		limit:     limit,
   203  	}
   204  }
   205  
   206  func (c *lineRangeCollector) rangeIsEmpty() bool {
   207  	return c.end <= c.start
   208  }
   209  
   210  func (c *lineRangeCollector) rangeIsCollected() bool {
   211  	return c.line >= c.end
   212  }
   213  
   214  func (c *lineRangeCollector) collectFrom(chunk []byte) error {
   215  	lineStart := 0
   216  	for lineStart < len(chunk) && !c.rangeIsCollected() {
   217  		newline := bytes.IndexByte(chunk[lineStart:], '\n')
   218  		lineEnd := len(chunk)
   219  		if newline >= 0 {
   220  			newline += lineStart
   221  			lineEnd = newline
   222  		}
   223  		if c.line >= c.start {
   224  			if err := c.collect(chunk[lineStart:lineEnd], newline >= 0); err != nil {
   225  				return err
   226  			}
   227  		}
   228  		if newline < 0 {
   229  			break
   230  		}
   231  		c.line++
   232  		lineStart = newline + 1
   233  	}
   234  	return nil
   235  }
   236  
   237  func (c *lineRangeCollector) collect(lineBytes []byte, newlineTerminated bool) error {
   238  	c.collected = append(c.collected, lineBytes...)
   239  	if newlineTerminated && c.line+1 < c.end {
   240  		c.collected = append(c.collected, '\n')
   241  	}
   242  	if int64(len(c.collected)) > c.limit {
   243  		return c.overLimitError()
   244  	}
   245  	return nil
   246  }
   247  
   248  func (c *lineRangeCollector) overLimitError() error {
   249  	if c.end-c.start == 1 {
   250  		return &ToolError{Content: fmt.Sprintf("read: line %d of %s alone exceeds %d-byte limit. The read tool cannot return part of a line, so view_range cannot narrow this further.",
   251  			c.start+1, c.filePath, c.limit)}
   252  	}
   253  	return &ToolError{Content: fmt.Sprintf("read: view_range [%d, %d] of %s exceeds %d-byte limit. Narrow the view_range to read a smaller portion.",
   254  		c.startLine, c.endLine, c.filePath, c.limit)}
   255  }
   256  
   257  func (c *lineRangeCollector) text() string {
   258  	return string(c.collected)
   259  }
   260  
   261  func execWrite(_ context.Context, raw json.RawMessage, env *AgentToolContext) (string, bool) {
   262  	var in anthropic.BetaManagedAgentsAgentToolset20260401WriteInput
   263  	if err := json.Unmarshal(raw, &in); err != nil {
   264  		return errorf("invalid write input: %v", err)
   265  	}
   266  	if in.FilePath == "" {
   267  		return errorf("write: file_path is required")
   268  	}
   269  	path, err := resolveWritablePath(env, in.FilePath)
   270  	if err != nil {
   271  		return errorf("write: %v", err)
   272  	}
   273  	if dir := filepath.Dir(path); dir != "" {
   274  		if err := os.MkdirAll(dir, 0o755); err != nil {
   275  			return errorf("write %s: mkdir: %s", in.FilePath, fsErrorMessage(err))
   276  		}
   277  	}
   278  	if err := atomicWriteFile(path, []byte(in.Content), 0o644); err != nil {
   279  		return errorf("write %s: %s", in.FilePath, fsErrorMessage(err))
   280  	}
   281  	return fmt.Sprintf("wrote %d bytes to %s", len(in.Content), in.FilePath), false
   282  }
   283  
   284  func execEdit(_ context.Context, raw json.RawMessage, env *AgentToolContext) (string, bool) {
   285  	var in anthropic.BetaManagedAgentsAgentToolset20260401EditInput
   286  	if err := json.Unmarshal(raw, &in); err != nil {
   287  		return errorf("invalid edit input: %v", err)
   288  	}
   289  	if in.FilePath == "" {
   290  		return errorf("edit: file_path is required")
   291  	}
   292  	if in.OldString == "" {
   293  		return errorf("edit: old_string is required")
   294  	}
   295  	path, err := resolveWritablePath(env, in.FilePath)
   296  	if err != nil {
   297  		return errorf("edit: %v", err)
   298  	}
   299  	// Stat before any open: the size cap stops a multi-GB file from OOM'ing
   300  	// the runner, and the mode check rejects FIFOs/devices/dirs before
   301  	// open() can block on them.
   302  	info, err := os.Stat(path)
   303  	if err != nil {
   304  		return errorf("edit %s: %s", in.FilePath, fsErrorMessage(err))
   305  	}
   306  	if !info.Mode().IsRegular() {
   307  		return errorf("edit: %s is not a regular file", in.FilePath)
   308  	}
   309  	if limit, capped := resolveMaxBytes(env.MaxFileBytes, defaultMaxFileBytes); capped && info.Size() > limit {
   310  		return errorf("edit: %s is %d bytes, exceeds %d-byte limit. The edit tool loads the whole file and cannot modify a file this large.",
   311  			in.FilePath, info.Size(), limit)
   312  	}
   313  	data, err := os.ReadFile(path)
   314  	if err != nil {
   315  		return errorf("edit %s: %s", in.FilePath, fsErrorMessage(err))
   316  	}
   317  	content := string(data)
   318  
   319  	count := strings.Count(content, in.OldString)
   320  	if count == 0 {
   321  		return errorf("edit: old_string not found in %s", in.FilePath)
   322  	}
   323  	var updated string
   324  	if in.ReplaceAll {
   325  		updated = strings.ReplaceAll(content, in.OldString, in.NewString)
   326  	} else {
   327  		if count > 1 {
   328  			return errorf("edit: old_string appears %d times in %s (must be unique)", count, in.FilePath)
   329  		}
   330  		updated = strings.Replace(content, in.OldString, in.NewString, 1)
   331  	}
   332  	if err := atomicWriteFile(path, []byte(updated), 0o644); err != nil {
   333  		return errorf("edit %s: %s", in.FilePath, fsErrorMessage(err))
   334  	}
   335  	return fmt.Sprintf("edited %s (%d replacement(s))", in.FilePath, count), false
   336  }
   337  
   338  // atomicWriteFile writes data to a temp file in the destination directory and
   339  // renames it over path, so a concurrent reader never observes a half-written
   340  // file and a failed write leaves the original intact. The write/edit file tools
   341  // go through this; rename is atomic only within a single filesystem, which
   342  // holds here because the temp file is created alongside the destination.
   343  func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
   344  	dir := filepath.Dir(path)
   345  	tmp, err := os.CreateTemp(dir, ".agenttoolset-*")
   346  	if err != nil {
   347  		return err
   348  	}
   349  	tmpName := tmp.Name()
   350  	defer func() {
   351  		// Best-effort cleanup if we bail before the rename succeeds.
   352  		_ = os.Remove(tmpName)
   353  	}()
   354  	if _, err := tmp.Write(data); err != nil {
   355  		_ = tmp.Close()
   356  		return err
   357  	}
   358  	if err := tmp.Chmod(perm); err != nil {
   359  		_ = tmp.Close()
   360  		return err
   361  	}
   362  	if err := tmp.Close(); err != nil {
   363  		return err
   364  	}
   365  	return os.Rename(tmpName, path)
   366  }