Skip to content

.NET: [BREAKING] Issue 7571 file access read lines - #7671

Open
Anton Sokolovskyi (antsok) wants to merge 4 commits into
microsoft:mainfrom
antsok:issue-7571-dotnet-file-access-read-lines
Open

.NET: [BREAKING] Issue 7571 file access read lines#7671
Anton Sokolovskyi (antsok) wants to merge 4 commits into
microsoft:mainfrom
antsok:issue-7571-dotnet-file-access-read-lines

Conversation

@antsok

@antsok Anton Sokolovskyi (antsok) commented Aug 14, 2026

Copy link
Copy Markdown

Motivation & Context

The harness file tools are 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. 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'; FileEditor split on '\n', '\r\n' and a lone '\r' and kept terminators. Consequences today:

  • On content with lone '\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.
  • On a newline-terminated file, grep can report a trailing line number the editor rejects as out of range.
  • Because grep strips terminators while NewLine is written verbatim, feeding a grepped line back into replace_lines joins it to the next line.

Description & Review Guide

  • What are the major changes?

    1. FileEditor.SplitLinesKeepEnds becomes internal and is used by both stores' SearchAsync, so grep line numbers address the same lines replace_lines edits, by construction.
    2. FileSearchMatch.Line is reported verbatim, terminator included.
    3. New file_access_read_lines tool, rendering <n>\t<line> with everything after the tab verbatim — so a row is already a valid replace_lines new_line.
  • What is the impact of these changes?
    Breaking for file_access_grep and file_memory_grep, in three ways: Line values 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 as match$ 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 — FileEditor is internal and Line keeps its type — so the Release build passing is not evidence of compatibility. ReadLinesToolName is a pure addition and needs no suppression.

    The parity holds for the two stores in this package, not for a custom one: file_access_grep runs through the public AgentFileStore.SearchAsync, whose contract does not prescribe a split, while read_lines and replace_lines split through the internal FileEditor. The XML docs on SplitLinesKeepEnds, FileSearchMatch.Line and ReadLinesAsync now say so. Specifying the split on the store contract would make it enforceable, and is left as a maintainer call.

    file_access_read_lines joins the read-only tool set, so it is exposed under DisableWriteTools and covered by ReadOnlyToolsAutoApprovalRule — 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. FileSystemAgentFileStoreTests previously asserted no Line values 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

  • The code builds cleanly without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue, and there is no other open PR for this issue
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Copilot AI balanced review requested due to automatic review settings August 14, 2026 20:17
@agent-framework-automation agent-framework-automation Bot added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs .NET Usage: [Issues, PRs], Target: .Net breaking change Usage: [PRs], Target: all PRs that introduce changes that are not backward compatible labels Aug 14, 2026
@antsok Anton Sokolovskyi (antsok) changed the title .NET: [BREAKING] Add file_access_read_lines and align grep with the line editor .NET: [BREAKING] Issue 7571 file access read lines Aug 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs Outdated
Comment thread dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs Outdated
Anton Sokolovskyi (antsok) added a commit to antsok/agent-framework that referenced this pull request Aug 14, 2026
…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>
Anton Sokolovskyi (antsok) added a commit to antsok/agent-framework that referenced this pull request Aug 14, 2026
…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>
@antsok
Anton Sokolovskyi (antsok) requested a balanced review from Copilot August 14, 2026 20:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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. FileSystemAgentFileStore has 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 AgentFileStore implementations. file_access_grep delegates to the public AgentFileStore.SearchAsync, whose contract does not define splitting or terminator retention, while this method and replace_lines split 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)

Anton Sokolovskyi (antsok) added a commit to antsok/agent-framework that referenced this pull request Aug 17, 2026
…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>
Anton Sokolovskyi (antsok) added a commit to antsok/agent-framework that referenced this pull request Aug 17, 2026
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>
Comment thread dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.SearchAsync is 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 FileAccessProvider supports custom AgentFileStore implementations and SearchAsync does 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 Substring creates another string, searching a newline-heavy large file now allocates a second copy of nearly all its text in addition to the strings from SplitLinesKeepEnds. Return the searchable content length (or terminator length) and use Regex.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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Usage: [PRs], Target: all PRs that introduce changes that are not backward compatible documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs .NET Usage: [Issues, PRs], Target: .Net

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants