.NET: [BREAKING] Add file_access_read_lines and move the line-numbering contract onto AgentFileStore - #7671
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>
From @westey-m's review. Policing implementations that are not per spec is not worth the cost at runtime; the expectation belongs on the type an implementer is reading when they get it wrong. A conformance test library, which is the real answer for checking an implementation, is tracked separately as microsoft#7931. SearchAlignment and BaseSearchResults are deleted, along with ReportsAlignedLineNumbers and its two overrides and DisableSearchAlignmentCheck on both provider options. SearchAsync returns its results directly instead of a tagged list, and neither provider verifies what a store reports. The contract those types enforced now sits on SearchAsync itself: a store overriding it must number FileSearchMatch.LineNumber as a 1-based coordinate into SplitLines of the content ReadAsync returns, ScanContent builds that correctly, and numbering against anything else is a bug whose failure mode is silent -- the search looks right, and the damage appears when a later line edit applies to a line the caller never saw. Two doc comments elsewhere still said the tools verify the numbering, which is no longer true of either. The FileSearchMatch and FileEditor remarks also scoped the rule to the stores shipped here rather than stating what implementers owe; both now read as expectations, which the same review asked for. Descriptions that state the line-counting rule now also say the numbers are 1-based, so a model counting lines from a whole-file read knows where to start. Eight tests went with the check: seven alignment cases and their six helper stores in AgentFileStoreContractTests, and the memory-refusal test with its skewed store in FileMemoryProviderTests. Nothing exercised the removed types otherwise; the remaining 2099 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-access-read-lines
From Copilot review 5051570368. Both are fallout from 72a1f03 rather than pre-existing: removing SkewedMemoryStore took the last CancellationToken out of FileMemoryProviderTests, and removing the shipped-stores trust test took the last Path.GetTempPath() out of AgentFileStoreContractTests, leaving System.Threading and System.IO imported for nothing. Neither surfaced in the build because unused usings are not an error here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…est rationale From Copilot review 5052352338. SearchFiles_SnippetIsAnchoredAtTheMatch exercised the per-line offset with LF only, so it would have passed had ScanContent advanced by content length plus one rather than by the line's own length -- correct for LF and a lone CR, a character short for CRLF. It is now a theory over all three, and the expected snippet derives from the terminator length instead of hardcoding 49 padding characters. Confirmed by making that exact substitution in ScanContent: the CRLF case fails and the other two pass, which is what the single-Fact version could not see. The comment above the mirrored filesystem search tests still said that store carries its own copy of the search loop. It does not: both stores now number through AgentFileStore.ScanContent, which was one of the goals of this PR, so the rationale contradicted the code. Reworded as what those tests actually add -- the same rules exercised against real files, where content arrives through a decoded read rather than an in-memory string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs:522
- The new tool's approval-group wiring is not covered in the isolated option tests:
DisableReadOnlyToolApproval_ReadOnlyToolsNotWrappedAsyncandDisableWriteToolApproval_WriteToolsNotWrappedAsyncomitReadLinesToolName. If this argument were accidentally changed towriteRequiresApproval, all current approval tests would still pass, reversing the approval boundary for this read tool. AddAssertRequiresApprovalchecks forReadLinesToolNameto both tests.
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadLinesAsync, new AIFunctionFactoryOptions { Name = ReadLinesToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
From the suppressed comment on Copilot review 5053203365. DisableReadOnlyToolApproval_ReadOnlyToolsNotWrapped and its write counterpart enumerated read, ls and grep on one side and the four store-modifying tools on the other, but never read_lines. Nothing else discriminated which group it is wired into: the auto-approval theory covers a different mechanism, and DisableBothToolApprovals only counts tools. Wiring read_lines to writeRequiresApproval would therefore have reversed the approval boundary for a read tool with every test still green. Both tests now assert it alongside the other read-only tools. Confirmed by making that substitution in FileAccessProvider: both fail, and the other 2114 tests pass, which is what makes the gap the finding describes visible rather than theoretical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs:38
read_linesreturns<number>\t<line>, not the line text by itself. This wording can make callers pass the whole row asexpected_line, which will always fail because the editor compares only the actual line text. Clarify that only the text after the first tab should be used; this also avoids implying thatread_linesexists when this model is used byFileMemoryProvider.
[Description("Optional: the text you believe is currently on that line, as reported by grep or read_lines. When supplied, the edit is rejected unless it matches, which catches an out-of-date line number or a file that changed since you looked. The trailing newline is ignored in the comparison.")]
From the suppressed comment on Copilot review 5053896798. The expected_line description told the caller to use the text "as reported by grep or read_lines". read_lines prefixes each line with its number and a tab, so a model following that literally can pass the whole row, which never matches: the editor compares the line's own text. It now says to give the line text only and names the prefix it must not include. read_lines is also the wrong tool to name here at all. FileLineEdit is shared by FileAccessProvider and FileMemoryProvider, and the memory provider registers no read_lines tool, so half the callers were pointed at something that does not exist for them. grep is named instead, which both providers do register. The XML doc above the property carried the same gap and now states the rule too, since that is what an implementer reads rather than the tool description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs:170
SearchAsyncis case-insensitive, and the base glob matcher is also configured case-insensitively, but this narrowing-hook contract does not tell overrides to preserve case-only candidates. A backend that pushes either hint into a case-sensitive index can therefore under-return and silently lose matches before the base re-scan. Document the comparison semantics explicitly and require widening when the index cannot reproduce them.
/// <param name="regexPattern">The pattern <see cref="SearchAsync"/> was called with, as a hint.</param>
/// <param name="globPattern">The optional glob, matched against each file's path relative to <paramref name="directory"/>.</param>
dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs:95
- The check requires exact equality after removing terminators, not containment. For example, actual text
foobarand expected textfootrigger this message even though the line does contain the expected text, which gives the model a false diagnostic. Say that the line “does not match the expected text” and update the corresponding assertion.
$"line_number {edit.LineNumber} does not contain the expected text. " +
"Re-read the file to get current line numbers.");
…on a mismatch From the two suppressed comments on Copilot review 5058964476. FindMatchingFilesAsync took the regex and the glob as hints without saying how either is compared. SearchAsync builds its regex with RegexOptions.IgnoreCase and StorePaths.CreateGlobMatcher uses OrdinalIgnoreCase, so a backend pushing either hint into a case-sensitive index returns only case-exact candidates and drops matches before the base ever re-scans. That is the under-return the superset rule exists to forbid, and nothing in the contract warned against it. Both parameters now state the comparison and require widening when the backend cannot reproduce it. The expected_line mismatch said the line "does not contain the expected text" while the check is exact equality after trimming terminators. For actual foobar and expected foo the line does contain it, so the model was handed a diagnostic that contradicts what it can see and would send it re-reading a file that had not changed. It now says the line does not match, and the contract test that pinned the old wording follows. Confirmed the assertion discriminates by putting the old wording back: ApplyReplaceLines_ExpectedLineDiffering_Throws fails and the other 2115 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| public virtual async Task<IReadOnlyList<FileSearchResult>> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default) | ||
| { | ||
| // Compile with a match timeout to guard against catastrophic backtracking (ReDoS). | ||
| var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); | ||
| IReadOnlyList<string> names = await this.FindMatchingFilesAsync(directory, regexPattern, globPattern, recursive, cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
westey (@westey-m) hi. I need your help here to make the decision whether this needs to be an ADR.
Motivation & Context
Two defects, one of which only became visible while fixing the other.
The reading gap (#7571). 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.Grep and the editor did not agree on what a line is. The stores split on
'\n'and stripped'\r';FileEditorsplit on'\n','\r\n'and a lone'\r'and kept terminators. That is not a cosmetic difference:'\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_linesjoined it to the next line.The same hole, one level down — and this is new scope since the first version of this PR. Unifying the splitter fixes the two stores in this package, but
AgentFileStore.SearchAsyncwasabstractwith no numbering contract at all, whileread_linesandreplace_linesre-count fromReadAsync. So a custom store could make "line 5" mean two different things, andreplace_lineswould edit the wrong line — in range, reporting success.FileMemoryProviderhad the identical hole.The previous revision of this PR documented that as a caveat and called it "a maintainer call". @moonbox3 made that call on the Python PR #7669: the rule should live on the contract, not in a doc comment. This PR now mirrors #7669 so both SDKs carry the same contract.
Description & Review Guide
What are the major changes?
The reading gap:
FileEditor.SplitLinesKeepEndsbecomesinternaland is the single definition of a line.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.The contract, now on the base class:
4.
SplitLines()publishes the split everyline_numberaddresses. Per-SDK by design: it need not match Python, only be consistent here, because a line number never crosses runtimes.5.
ScanContent()is the numbering primitive. Both shipped stores report through it, which also removes the scan loop that was duplicated between them.6.
FindMatchingFilesAsync()is a new hook for narrowing to the files worth reading. The regex goes down as a hint with superset semantics — over-returning is harmless because the base re-scans, under-returning loses matches. A backend with a native index overrides it and narrows server-side. Its default is built fromListChildrenAsync, so a store implementing nothing beyond the mandatory members gets aligned numbers for free.7.
SearchAsyncis no longer abstract. It reads and numbers candidates itself, and re-applies the glob and the non-recursive rule, since the hook may over-return.8. Overriding
SearchAsyncstays first-class — a backend that can do the whole job natively should — but then it owns numbering, and nothing checks it at runtime.SearchAsyncdocuments what is owed:LineNumberis a 1-based coordinate intoSplitLinesof the contentReadAsyncreturns,ScanContentproduces that correctly, and getting it wrong fails silently — the search looks right and a later line edit lands on a line the caller never saw. See focus item 3.9.
FileLineEdit.ExpectedLine— when supplied, the edit is refused unless the target line still says what the caller saw. Catches splitter drift, a stale line number, and the file changing between read and write.Added during review, after the items above:
file_access_readand then edits by line number: it has to count the lines itself, and nothing told it the rule.read,read_lines,replace_lines,grepand the two memory tools now say that lines are terminated by\r\n,\nor a lone\r, that each line keeps its terminator, and that content ending in a terminator has no extra empty line after it. Taken from the casesFileEditorTestsalready pins rather than from reading the splitter. They also state that the numbers are 1-based, so a model counting lines from a whole-file read knows where to start.grepwhen a store's numbers disagreed withSplitLines. @westey-m's call: that is runtime cost for all users to police implementations that are not per spec, and a spec violation is the implementer's bug.SearchAlignment,BaseSearchResults,ReportsAlignedLineNumbersandDisableSearchAlignmentCheckare gone; the expectation now lives on the members an implementer overrides. A conformance test library is the better answer and is tracked separately as .NET: Add Conformance Test Library for Agent Framework #7931.What is the impact of these changes?
Breaking, in these ways:
FileSearchMatch.Lineincludes terminatorsgrepoutput'\r'content and on a trailing newlinematch$now matches on a CRLF line where it could not before, and a pattern targeting a literal'\r'no longer matches the one such a line ends withSearchAsyncis no longerabstractoverridestill works)The whole surface is
[Experimental("MAAI001")]. ApiCompat does not flag any of it —FileEditoris internal andLinekeeps its type — so a passing Release build is not evidence of compatibility. Removingabstractwhile keeping the membervirtualdoes pass Package Validation; verified by building the package in Release withIsReleased=true.Two further changes are not API breaks but do change what the model is told: every line-addressing tool description gains the counting rule, and the
expected_linemismatch message no longer echoes the line it found — raised in review as a read oracle where write tools are auto-approved while read tools are not, and applied in 30115b0.Cost. None: no tool re-reads a file, and
SearchAsyncreturns its results directly.The narrowing hook is not a speed-up and is not sold as one — it is "same speed, now safe". A selectivity sweep against a store doing the whole job in its own
SearchAsync:SearchAsyncPer-method timings across local disk, in-memory, Azure Blob and Redis show no method-level effect; happy to attach the full table if useful.
file_access_read_linesjoins the read-only tool set, so it is exposed underDisableWriteToolsand covered byReadOnlyToolsAutoApprovalRule— the auto-approval docs and sample security notes are updated accordingly.What do you want reviewers to focus on?
Whether the contract belongs on
AgentFileStoreat all — that is the substantive question, and it is a maintainer call being made here rather than assumed.The superset semantics of
FindMatchingFilesAsync. Over-returning is harmless, under-returning silently loses matches. Whether that is documented clearly enough for someone implementing it against a native index.Whether documentation alone is enough to carry the numbering contract. Earlier revisions tracked "did the base number these?" by tagging the returned list and had the providers verify it; that is removed per change 11 above. What replaces it is the
<remarks>onSearchAsync, plusSplitLinesandScanContentbeing the published primitives an implementer is meant to build on. The open question is whether an implementer overridingSearchAsyncwill actually read it there, given the failure mode is silent and shows up in someone else's edit.This is where the two PRs now diverge. Python: [BREAKING] Add file_access_read_lines and move the line-numbering contract onto AgentFileStore #7669 (Python) still verifies at runtime and was hardened in that direction on 25 Aug. That divergence is not deliberate design — it is one maintainer preference applied to one PR — so it is worth a view on whether Python should follow.
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.Two deliberate divergences from the Python half (Python: [BREAKING] Add file_access_read_lines and move the line-numbering contract onto AgentFileStore #7669):
Line-rule parity note: 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 actually depends on.Related Issue
#7571 — linked without a closing keyword on purpose: the Python half ships as #7669, and the issue should stay open until both land. Will change to
Closesin the last one.Note that #7571's body still says no store-protocol change is needed; that predates the review discussion above and is no longer accurate for either language.
Contribution Checklist
--warnaserroracross all five TFMs, Package Validation includedAgentFileStoreContractTestsbreaking 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.