Skip to content

feat(go/plugins/middleware): rework filesystem middleware#5202

Open
apascal07 wants to merge 3 commits intomainfrom
ap/go-filesystem
Open

feat(go/plugins/middleware): rework filesystem middleware#5202
apascal07 wants to merge 3 commits intomainfrom
ap/go-filesystem

Conversation

@apascal07
Copy link
Copy Markdown
Collaborator

Rebuilds the filesystem middleware around the patterns established by tool-calling code agents: structured edits, a per-call file-state cache, and explicit safety guards. The previous SEARCH/REPLACE text-block parser is replaced with an edit_file tool that takes JSON-shaped edits — no markers, no whitespace edge cases. Read/write/edit are now coordinated through a shared cache that enforces read-before-edit, detects external modifications, and dedups unchanged re-reads.

Tool surface

list_files

Entries now report byte size for files (omitted for directories):

{Path: "todo.txt", IsDirectory: false, SizeBytes: 412}
{Path: "docs",     IsDirectory: true}

read_file

Returns the file via the deferred user-message channel with a <read_file> envelope that carries metadata. New optional offset (1-indexed line) and limit parameters select a window. The header includes totalLines and (for sliced reads) lines="X-Y" so the model can reason about scope. A 256 KiB byte cap on full reads bails large files with a hint to use offset/limit.

<read_file path="todo.txt" totalLines="42">
...
</read_file>

<read_file path="big.log" lines="100-120" totalLines="50000">
...
</read_file>

Re-reading the same file at the same range with no on-disk modification returns a stub instead of re-injecting the bytes:

File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.

write_file

Creating a new file works as before. Overwriting an existing file now requires a prior read_file in this session — the cache must contain an entry whose mtime matches disk. This catches the lost-update class of bug where the model writes over an external change it never saw. The cache is updated after a successful write.

edit_file (replaces search_and_replace)

Takes structured edits — no markers, no parsing:

{
  "filePath": "todo.txt",
  "edits": [
    {"oldString": "- [ ] Write a smoke test for /health.\n", "newString": ""},
    {"oldString": "TODO\n====", "newString": "TODO\n====\nLast updated: 2026-04-28"}
  ]
}

Per-edit replaceAll handles renames. Edits apply sequentially — each sees the result of the previous. Same require-read-first and mtime-staleness guards as write_file.

applyEdit rules:

  • empty oldString → error
  • oldString == newString → error
  • no match → error with byte-for-byte hint
  • multi-match without replaceAll → error citing match count
  • otherwise strings.Replace (one) or strings.ReplaceAll (all)

Safety guarantees

Failure mode Guard
Edit a file the model never read edit_file and overwrite-write_file refuse without a cache entry
User edits the file in their editor between read and edit mtime check refuses; model re-reads
Two concurrent edits race on the same path per-path mutex around stat → write → cache-update
Re-reading an unchanged file stub response; the original tool result already lives in history
oldString matches multiple locations silently error citing the exact count, suggesting replaceAll or more context
oldString == newString error
Reading a 50 MB file in full 256 KiB cap; hint to use offset/limit

All guards return actionable error messages so the model can self-correct on the next turn.

Internal additions

  • fileStateCache: per-call, FIFO-bounded (200 entries) path → {content, mtime, offset, limit} map. Allocated in New(), scoped to one Hooks instance.
  • pathLocks: path → *sync.Mutex for serializing read-modify-write.
  • countLines, sliceLines: helpers for read_file metadata and windowing.

What was deliberately deferred

  • File content in the tool_result instead of a deferred user message. read_file still injects content as a separate user message on the next turn (via enqueueParts). Putting it directly in MultipartToolResponse is structurally cleaner — single block per read, no protocol-awkward consecutive user messages — but works today and is non-breaking to defer.
  • Line numbers in read_file output. Useful for reasoning about line positions, but pairs with a footgun (the model can copy line-number prefixes into edit blocks where they don't match disk bytes). Structured edits don't need them.
  • Partial-view tracking on edits. The cache records offset/limit, but edit_file doesn't yet refuse edits whose oldString falls outside a partial-read window. Mtime check is sufficient for the common case.

Sample

samples/basic-middleware/filesystem exercises the full surface (read_file → edit_file with oldString"" to delete) against a tiny workspace. MaxTurns bumped from 12 to 20 to give the model room to recover from any first-attempt format errors during interactive testing.

Test coverage

24 tests, all passing. New coverage:

  • Structured edit semantics: not-found, multi-match + replaceAll, equal old/new, empty old, deletion via empty new
  • Integration: edit_file happy path, edit_file failure surfacing, require-read-first on edit, require-read-first on overwrite, external-modification detection, dedup stub, read_file offset/limit, list_files size metadata, read_file line metadata

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the filesystem middleware by implementing a file state cache and path-based locking to ensure consistency and prevent race conditions. The read_file tool is updated to support line-based slicing with offset and limit parameters, and the search_and_replace tool is replaced by a structured edit_file tool. New safety measures include a "read-before-write" requirement and staleness checks to detect external file modifications. Review feedback highlights the need for a more efficient and accurate line-slicing implementation, potential memory risks when reading large files, and the importance of precise output formatting to maintain byte-for-byte matching for the model.

Comment thread go/plugins/middleware/filesystem.go
Comment thread go/plugins/middleware/filesystem.go Outdated
Comment thread go/plugins/middleware/filesystem.go
Comment thread go/plugins/middleware/filesystem.go
Comment thread go/plugins/middleware/filesystem.go Outdated
- sliceLines: byte-position slicing instead of Split/Join — preserves
  line terminators inside the window and avoids the string round-trip.
- read_file: acquire per-path lock around stat → cache-check → read →
  cache-set. genkit runs tool calls concurrently, so a parallel
  read_file/edit_file on the same path could otherwise let the read
  clobber the edit's cache update with stale state.
- read_file: add a 10 MiB hard cap on file size before root.ReadFile.
  Even sliced reads materialize the full file, so without this a 1 GB
  log would OOM the process before sliceLines runs.
- read_file: drop the fabricated "\n" before </read_file>. When file
  content already ends in a newline, the file's own terminator
  separates it from the close tag; when it doesn't, manufacturing one
  invites the model to copy it into edit_file.oldString and miss the
  byte-for-byte match.
@apascal07 apascal07 requested review from huangjeff5 and pavelgj April 28, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant