.NET: [BREAKING] Issue 7571 file access read lines - #7671
.NET: [BREAKING] Issue 7571 file access read lines#7671Anton Sokolovskyi (antsok) wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds line-range reading and aligns .NET grep results with line-editing semantics.
Changes:
- Adds
file_access_read_lines. - Preserves line terminators across grep/read/edit workflows.
- Updates approvals, documentation, and tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
InMemoryAgentFileStoreTests.cs |
Tests updated grep semantics. |
FileEditorTests.cs |
Tests splitting and slicing. |
FileAccessProviderTests.cs |
Tests the new tool and approvals. |
HarnessAgentTests.cs |
Verifies tool exposure. |
InMemoryAgentFileStore.cs |
Aligns grep line handling. |
FileSystemAgentFileStore.cs |
Aligns filesystem grep behavior. |
FileSearchMatch.cs |
Documents verbatim lines. |
FileEditor.cs |
Adds shared splitting and slicing. |
FileAccessProviderOptions.cs |
Documents read-only tool behavior. |
FileAccessProvider.cs |
Implements file_access_read_lines. |
Harness_Step03_DataProcessing/README.md |
Updates security guidance. |
Claw_Step02_WorkingWithData/README.md |
Updates security guidance. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
…as the schema does Addresses both review comments on microsoft#7671. TrimTrailingNewline removed only "\n", so grep matched against text such as "match\r" on CRLF and lone-CR lines and an end-anchored pattern like "match$" failed even though the line's text was exactly "match". Renamed to TrimLineTerminator and it now strips "\r\n", "\n", or a lone "\r". The file_access_read_lines description and the SliceLines failure messages referred to end_line/start_line, but the generated schema exposes the arguments as endLine/startLine, so the model could be prompted to emit an invalid argument name. Both now use the schema's names. (new_line is left as-is: FileLineEdit sets it explicitly via JsonPropertyName.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s_grep Ports the fix for the same defect found by review on the .NET side (microsoft#7671). _search_file_content removed only the trailing "\n" before matching, so on a CRLF file the pattern was applied to text such as "beta match\r" and an end-anchored pattern like "match$" failed even though the line's text is exactly "beta match". The terminator is not part of the line's text, so it is stripped in full now. The per-line offset had to move with it: it advanced by len(scanned) + 1, which was only correct while scanned still carried the "\r". It now advances by len(line), whose terminator is already included, keeping the snippet anchored at the match. Also drops a stale claim in _split_lines_keepends' docstring, which still said it reproduced _search_file_content's content.split("\n") — that dependency now runs the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs:209
- The new newline, line-number, and snippet-offset behavior is tested only against
InMemoryAgentFileStore.FileSystemAgentFileStorehas its own copied search loop and an existing comprehensive search test suite, so a store-specific regression here would pass. Add equivalent CRLF, lone-CR, trailing-newline, anchored-pattern, and snippet-offset coverage for this implementation.
// Lines keep their terminators, so these line numbers address the same lines that
// replace_lines edits and each reported line can be reused as a literal new_line.
List<string> lines = FileEditor.SplitLinesKeepEnds(fileContent);
dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs:326
- The advertised line-number parity is not guaranteed for custom
AgentFileStoreimplementations.file_access_grepdelegates to the publicAgentFileStore.SearchAsync, whose contract does not define splitting or terminator retention, while this method andreplace_linessplit independently through an internal-only helper. An existing custom store can therefore return a grep number that reads or edits a different line. Define the required semantics on the public store contract and make a shared implementation available (or centralize line matching above the store) before promising parity.
[Description("Read part of a file by 1-based inclusive line number; omit endLine to read to the end of the file, and an endLine past the last line is clamped. Line numbers match file_access_grep and file_access_replace_lines. Each line is prefixed with its number and a tab; everything after that tab is verbatim, including the line's own terminator, so it can be reused as a file_access_replace_lines new_line.")]
private async Task<string> ReadLinesAsync(string fileName, int startLine, int? endLine = null, CancellationToken cancellationToken = default)
…s_grep Ports the fix for the same defect found by review on the .NET side (microsoft#7671). _search_file_content removed only the trailing "\n" before matching, so on a CRLF file the pattern was applied to text such as "beta match\r" and an end-anchored pattern like "match$" failed even though the line's text is exactly "beta match". The terminator is not part of the line's text, so it is stripped in full now. The per-line offset had to move with it: it advanced by len(scanned) + 1, which was only correct while scanned still carried the "\r". It now advances by len(line), whose terminator is already included, keeping the snippet anchored at the match. Also drops a stale claim in _split_lines_keepends' docstring, which still said it reproduced _search_file_content's content.split("\n") — that dependency now runs the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
file_access_grep runs through AgentFileStore.search, whose contract says nothing about how content is split or whether terminators survive, while read_lines and replace_lines split through the module-private _split_lines_keepends. A custom store can therefore report a line number that addresses a different line than the two editing tools do — the wrong-line edit this branch exists to prevent, moved to custom stores. The claim was written as unconditional in four places, so _split_lines_keepends, _slice_lines, FileSearchMatch.line and AGENTS.md now say where it holds and where it does not. AGENTS.md also still described matching as stripping only the trailing "\n" and anchoring "as before", which stopped being true in 7aa29c6. Corrected to the whole terminator, in the same wording as the PR description. The read_lines tool docstring is left unhedged on purpose: it is prompt text, and teaching the model to doubt the line numbers would send it back to whole-file reads, which is the cost this branch exists to remove. Found while reviewing the .NET port (microsoft#7671), where Copilot raised the same gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ine editor
The harness file tools were line-precise when editing (file_access_replace_lines takes
1-based line numbers and file_access_grep reports them) but all-or-nothing when reading,
so there was no way to see the lines around a match without reading the whole file.
Adds file_access_read_lines, rendering each row as `<n>\t<line>` with everything after
the tab verbatim, including the line's own terminator, so a row feeds straight back into
file_access_replace_lines.
That contract only holds if grep and the line editor agree on what a line is, and they
did not. The stores split on '\n' and stripped '\r'; FileEditor split on '\n', '\r\n'
and a lone '\r' and kept terminators. So on a file using lone '\r' terminators, grep's
line 1 addressed only part of what the model was shown and editing by that number
silently changed the wrong text, and on a newline-terminated file grep could report a
trailing line number the editor rejected as out of range. Both stores now use
FileEditor.SplitLinesKeepEnds and report the matching line verbatim.
BREAKING: FileSearchMatch.Line now includes the line's terminator, and line numbers
change on content containing a lone '\r' or a trailing newline. This affects
file_access_grep and file_memory_grep. The whole surface is [Experimental("MAAI001")].
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as the schema does Addresses both review comments on microsoft#7671. TrimTrailingNewline removed only "\n", so grep matched against text such as "match\r" on CRLF and lone-CR lines and an end-anchored pattern like "match$" failed even though the line's text was exactly "match". Renamed to TrimLineTerminator and it now strips "\r\n", "\n", or a lone "\r". The file_access_read_lines description and the SliceLines failure messages referred to end_line/start_line, but the generated schema exposes the arguments as endLine/startLine, so the model could be prompted to emit an invalid argument name. Both now use the schema's names. (new_line is left as-is: FileLineEdit sets it explicitly via JsonPropertyName.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mber claim
FileSystemAgentFileStore carries its own copy of the search loop, and its tests
asserted only a line number of 1 and Assert.Contains on the snippet — no test in
that file ever asserted a FileSearchMatch.Line value. Reverting its loop to
Split('\n') with TrimEnd('\r') left the whole suite green, so the store that
touches real files had no coverage of anything this branch changed. Mirrors the
six search tests from InMemoryAgentFileStoreTests; six of the eight new cases fail
against the reverted loop, and the snippet test fails on its own if the per-line
advance regains the "+ 1" that the terminator-keeping split made wrong.
Also scopes the parity claim to the stores in this package. file_access_grep runs
through the public AgentFileStore.SearchAsync, whose contract says nothing about
how content is split or whether terminators survive, while read_lines and
replace_lines split through FileEditor — which is internal, so a custom store
cannot reuse it even deliberately. Promising that the numbers always agree is
therefore something this provider cannot honour. FileEditor.SplitLinesKeepEnds,
FileSearchMatch.Line and ReadLinesAsync now say where the guarantee holds and
where it does not.
The tool's [Description] is left unhedged on purpose: it is prompt text, and
teaching the model to doubt the line numbers would send it back to whole-file
reads, which is the cost this branch exists to remove.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aa0aeea to
97e0e1c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs:332
- The generated tool description promises line-number parity unconditionally, contradicting this method's remarks for custom stores. Because this text is sent to the model, a custom store can still trigger the wrong-line edit this change is intended to prevent. Qualify the claim to built-in stores unless
AgentFileStore.SearchAsyncis given a matching line-splitting contract.
[Description("Read part of a file by 1-based inclusive line number; omit endLine to read to the end of the file, and an endLine past the last line is clamped. Line numbers match file_access_grep and file_access_replace_lines. Each line is prefixed with its number and a tab; everything after that tab is verbatim, including the line's own terminator, so it can be reused as a file_access_replace_lines new_line.")]
dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs:142
- This workflow assumes grep and the editor share line semantics, but
FileAccessProvidersupports customAgentFileStoreimplementations andSearchAsyncdoes not require that split. For such a store, following this instruction can silently edit a different line. Qualify the workflow as applying to the built-in stores, or define the shared line semantics in the public store contract.
This issue also appears on line 332 of the same file.
- To change part of a file, find the line numbers with `file_access_grep`, read the range around them
with `file_access_read_lines`, then edit with `file_access_replace_lines`. Reading the whole file
first is rarely necessary.
dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs:145
- Both search implementations call this helper for every line before knowing whether it matches. Since each
Substringcreates another string, searching a newline-heavy large file now allocates a second copy of nearly all its text in addition to the strings fromSplitLinesKeepEnds. Return the searchable content length (or terminator length) and useRegex.Match(line, 0, length)so matching excludes the terminator without copying every line.
internal static string TrimLineTerminator(string line)
…p copying every line Two findings from Copilot review 4950508867. The tool description told the model, unconditionally, that grep's line numbers match read_lines and replace_lines. That text reaches the model, and it is the sentence that licenses going straight from grep to replace_lines without reading the range — which is exactly the wrong-line edit this branch exists to prevent when the store is a custom one, since AgentFileStore.SearchAsync prescribes no split. Removed. The grep -> read_lines -> replace_lines workflow in DefaultInstructions stays: it routes through a read whose numbering shares FileEditor with the editor, so the model sees the text it is about to change. TrimLineTerminator copied every line before knowing whether it matched, so a search over a newline-heavy file allocated a second copy of nearly all its text on top of SplitLinesKeepEnds. It becomes LineContentLength, and both stores now call Regex.Match(line, 0, length), which bounds the match without copying. Verified equivalent across four line shapes and four patterns, match.Index included, so the snippet offsets are untouched. LineContentLength_BoundsAnEndAnchoredMatch pins the bound; on CRLF and lone-CR lines it fails without it, while the LF and unterminated cases pass either way because .NET's '$' already matches before a trailing newline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Motivation & Context
The harness file tools are line-precise when editing —
file_access_replace_linestakes 1-based line numbers andfile_access_grepreports them — but all-or-nothing when reading. There is no way to see the lines around a match without reading the whole file, so agents either re-read entire files or edit by a line number they never looked at.Reading a range only works if grep and the line editor agree on what a line is, and they did not. The stores split on
'\n'and stripped'\r';FileEditorsplit on'\n','\r\n'and a lone'\r'and kept terminators. Consequences today:'\r'terminators, grep's line 1 covers only part of what the model was shown, so editing by that number silently changes the wrong text.NewLineis written verbatim, feeding a grepped line back intoreplace_linesjoins it to the next line.Description & Review Guide
What are the major changes?
FileEditor.SplitLinesKeepEndsbecomesinternaland is used by both stores'SearchAsync, so grep line numbers address the same linesreplace_linesedits, by construction.FileSearchMatch.Lineis reported verbatim, terminator included.file_access_read_linestool, rendering<n>\t<line>with everything after the tab verbatim — so a row is already a validreplace_linesnew_line.What is the impact of these changes?
Breaking for
file_access_grepandfile_memory_grep, in three ways:Linevalues now include terminators; line numbers change on content with a lone'\r'or a trailing newline; and because the whole terminator is stripped before matching, an end-anchored pattern such asmatch$now matches on a CRLF line where it could not before, while a pattern targeting a literal'\r'no longer matches the one such a line ends with. The whole surface is[Experimental("MAAI001")]. ApiCompat does not flag any of it —FileEditoris internal andLinekeeps its type — so the Release build passing is not evidence of compatibility.ReadLinesToolNameis a pure addition and needs no suppression.The parity holds for the two stores in this package, not for a custom one:
file_access_grepruns through the publicAgentFileStore.SearchAsync, whose contract does not prescribe a split, whileread_linesandreplace_linessplit through the internalFileEditor. The XML docs onSplitLinesKeepEnds,FileSearchMatch.LineandReadLinesAsyncnow say so. Specifying the split on the store contract would make it enforceable, and is left as a maintainer call.file_access_read_linesjoins the read-only tool set, so it is exposed underDisableWriteToolsand covered byReadOnlyToolsAutoApprovalRule— the auto-approval doc lists and sample security notes are updated accordingly.What do you want reviewers to focus on?
Whether unifying the splitter is the right call versus leaving grep and the editor divergent, and the per-line snippet offset arithmetic in both stores now that terminators are part of each line.
FileSystemAgentFileStoreTestspreviously asserted noLinevalues at all, so the six search tests mirrored into it are where that arithmetic is now pinned for the disk-backed store.Note for parity: Python addresses a trailing empty line on
"a\nb\n"; .NET has two lines there, because .NET's line editor never had that phantom line. Each language stays self-consistent, which is what the grep → read → edit round trip depends on.Related Issue
#7571 — linked without a closing keyword on purpose: the Python half ships in a separate PR, and the issue should stay open until both land. Will change to Closes in the last one.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.