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 }