Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4a3b4bc
.NET: [BREAKING] Add file_access_read_lines and align grep with the l…
antsok Aug 14, 2026
71f287a
.NET: Strip the whole line terminator before matching, and name args …
antsok Aug 14, 2026
9670f4e
.NET: Cover the file-system store's search loop and scope the line-nu…
antsok Aug 17, 2026
9350917
.NET: Drop the parity promise from the read_lines description and sto…
antsok Aug 17, 2026
ba7e4d5
.NET: [BREAKING] Move the line-numbering contract onto AgentFileStore
antsok Aug 18, 2026
30115b0
Potential fix for pull request finding
antsok Aug 20, 2026
5f93585
.NET: Fix two holes in the search alignment check and four stale cont…
antsok Aug 20, 2026
17345dd
.NET: State the line-counting rule in the tools and give memory its o…
antsok Aug 20, 2026
acabb57
.NET: Make the base-results trust marker prove integrity, not just pr…
antsok Aug 20, 2026
72a1f03
.NET: Replace the runtime alignment check with a documented contract
antsok Aug 28, 2026
13787cc
Merge remote-tracking branch 'origin/main' into issue-7571-dotnet-fil…
antsok Aug 28, 2026
9dbd7a3
.NET: Drop two usings left dead by the alignment-check removal
antsok Aug 28, 2026
85b93b3
.NET: Pin the snippet offset for every terminator, and drop a stale t…
antsok Aug 28, 2026
4dd67a9
.NET: Cover the read_lines approval group in the isolated option tests
antsok Aug 28, 2026
7f49dc6
.NET: Say which part of a numbered read expected_line takes
antsok Aug 29, 2026
62676eb
.NET: State the hook's case semantics, and stop claiming containment …
antsok Aug 29, 2026
a3ff18c
Merge branch 'main' into issue-7571-dotnet-file-access-read-lines
antsok Sep 1, 2026
06d9df1
.NET: Say what FindMatchingFilesAsync narrows on, and why a store wou…
antsok Sep 3, 2026
d36ed6a
Merge branch 'main' into issue-7571-dotnet-file-access-read-lines
antsok Sep 3, 2026
8c836fd
.NET: Declare this PR's API surface for the new public API analyzers
antsok Sep 3, 2026
a6c8a3f
Merge remote-tracking branch 'upstream/main' into issue-7571-dotnet-f…
antsok Sep 3, 2026
25ca0c2
.NET: Stop the default tree walk when the token is already cancelled
antsok Sep 4, 2026
27a4bc3
.NET: Guard ScanContent's fileName alongside its other parameters
antsok Sep 4, 2026
b887bce
Merge remote-tracking branch 'upstream/main' into issue-7571-dotnet-f…
antsok Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ It builds on Post 1's personal finance assistant and teaches it to work with *yo

> ⚠️ **Security — avoid tool-name collisions:** auto-approval rules such as
> `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` match tool calls **solely by tool name**. Any
> other registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
> other registered tool that shares one of the approved names (`file_access_read`,
> `file_access_read_lines`, `file_access_ls`,
> `file_access_grep`) would be silently auto-approved, bypassing the human
> approval boundary. Ensure no other tool's name collides with the reserved names a rule approves.
- **Durable memory, two ways:**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ E.g. try the following prompt `Please process the sales.csv file by first filter

This sample uses `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` to auto-approve read-only file
access tools. Built-in auto-approval rules match tool calls **solely by tool name**, so any other
registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
registered tool that shares one of the approved names (`file_access_read`, `file_access_read_lines`,
`file_access_ls`,
`file_access_grep`) would be **silently auto-approved**, bypassing the
human approval boundary. Ensure no other tool's name collides with the reserved names an
auto-approval rule approves.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -37,28 +38,30 @@ namespace Microsoft.Agents.AI;
/// <list type="bullet">
/// <item><description><c>file_access_write</c> — Write a file with the given name and content.</description></item>
/// <item><description><c>file_access_read</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_access_read_lines</c> — Read a range of lines from a file by line number.</description></item>
/// <item><description><c>file_access_delete</c> — Delete a file by name.</description></item>
/// <item><description><c>file_access_ls</c> — List the direct child files and subdirectories of a directory.</description></item>
/// <item><description><c>file_access_grep</c> — Recursively search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_access_replace</c> — Replace occurrences of a substring within a file.</description></item>
/// <item><description><c>file_access_replace_lines</c> — Replace whole lines within a file.</description></item>
/// </list>
/// When <see cref="FileAccessProviderOptions.DisableWriteTools"/> is set, only the read-only tools
/// (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>) are exposed.
/// (<c>file_access_read</c>, <c>file_access_read_lines</c>, <c>file_access_ls</c>, and
/// <c>file_access_grep</c>) are exposed.
/// </para>
/// <para>
/// By default, all of these tools require approval: each is exposed as an <see cref="ApprovalRequiredAIFunction"/>.
/// Approval can be disabled per group via <see cref="FileAccessProviderOptions.DisableReadOnlyToolApproval"/>
/// (read, ls, and grep) and <see cref="FileAccessProviderOptions.DisableWriteToolApproval"/>
/// (read, read_lines, ls, and grep) and <see cref="FileAccessProviderOptions.DisableWriteToolApproval"/>
/// (write, delete, replace, and replace_lines).
/// </para>
/// <para>
/// To auto-approve these tools without prompting, use the <see cref="ToolApprovalAgent"/> and add one of the provided rules to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/>:
/// <list type="bullet">
/// <item><description>
/// <see cref="ReadOnlyToolsAutoApprovalRule"/> — auto-approves only the read-only tools (read, ls,
/// and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
/// <see cref="ReadOnlyToolsAutoApprovalRule"/> — auto-approves only the read-only tools (read, read_lines,
/// ls, and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
/// </description></item>
/// <item><description>
/// <see cref="AllToolsAutoApprovalRule"/> — auto-approves every file access tool, including the tools that modify the store.
Expand All @@ -82,6 +85,9 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
/// <summary>The name of the tool that reads a file.</summary>
public const string ReadFileToolName = "file_access_read";

/// <summary>The name of the tool that reads a range of lines from a file.</summary>
public const string ReadLinesToolName = "file_access_read_lines";

/// <summary>The name of the tool that deletes a file.</summary>
public const string DeleteFileToolName = "file_access_delete";

Expand All @@ -101,6 +107,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
private static readonly HashSet<string> s_readOnlyToolNames = new(StringComparer.Ordinal)
{
ReadFileToolName,
ReadLinesToolName,
LsToolName,
GrepToolName,
};
Expand All @@ -110,6 +117,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
{
WriteToolName,
ReadFileToolName,
ReadLinesToolName,
DeleteFileToolName,
LsToolName,
GrepToolName,
Expand All @@ -129,6 +137,9 @@ These files persist beyond the current session and may be shared across sessions
or `file_access_grep` to search file contents recursively across the whole store.
- To make small edits to an existing file, prefer `file_access_replace` (substring replacement) or
`file_access_replace_lines` (whole-line replacement) over rewriting the whole file.
- To change part of a file, find the line numbers with `file_access_grep`, read the range around them
Comment thread
antsok marked this conversation as resolved.
with `file_access_read_lines`, then edit with `file_access_replace_lines`. Reading the whole file
first is rarely necessary.
""";

private readonly AgentFileStore _fileStore;
Expand Down Expand Up @@ -161,7 +172,8 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o

/// <summary>
/// Gets an auto-approval rule that approves the read-only file access tools
/// (<see cref="ReadFileToolName"/>, <see cref="LsToolName"/>, and <see cref="GrepToolName"/>).
/// (<see cref="ReadFileToolName"/>, <see cref="ReadLinesToolName"/>, <see cref="LsToolName"/>,
/// and <see cref="GrepToolName"/>).
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -179,6 +191,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o
/// This rule approves calls to exactly the following tool names:
/// <list type="bullet">
/// <item><description><see cref="ReadFileToolName"/> (<c>file_access_read</c>)</description></item>
/// <item><description><see cref="ReadLinesToolName"/> (<c>file_access_read_lines</c>)</description></item>
/// <item><description><see cref="LsToolName"/> (<c>file_access_ls</c>)</description></item>
/// <item><description><see cref="GrepToolName"/> (<c>file_access_grep</c>)</description></item>
/// </list>
Expand Down Expand Up @@ -213,6 +226,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o
/// <list type="bullet">
/// <item><description><see cref="WriteToolName"/> (<c>file_access_write</c>)</description></item>
/// <item><description><see cref="ReadFileToolName"/> (<c>file_access_read</c>)</description></item>
/// <item><description><see cref="ReadLinesToolName"/> (<c>file_access_read_lines</c>)</description></item>
/// <item><description><see cref="DeleteFileToolName"/> (<c>file_access_delete</c>)</description></item>
/// <item><description><see cref="LsToolName"/> (<c>file_access_ls</c>)</description></item>
/// <item><description><see cref="GrepToolName"/> (<c>file_access_grep</c>)</description></item>
Expand Down Expand Up @@ -292,14 +306,54 @@ private async Task<string> WriteAsync(string fileName, string content, bool over
/// <param name="fileName">The name of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")]
[Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}

/// <summary>
/// Read a range of lines from a file, each prefixed with its 1-based line number and a tab.
/// </summary>
/// <param name="fileName">The name of the file to read.</param>
/// <param name="startLine">The 1-based line number to read from.</param>
/// <param name="endLine">The 1-based line number to read through, inclusive. When <see langword="null"/>, reads to the end of the file.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The numbered lines, or a not-found message.</returns>
/// <remarks>
/// The line numbers agree with the ones <c>file_access_grep</c> reports, because
/// <see cref="AgentFileStore.SearchAsync"/> must number by <see cref="AgentFileStore.SplitLines"/> —
Comment thread
antsok marked this conversation as resolved.
/// the split this method and <c>file_access_replace_lines</c> use. A store overriding it owns that
/// numbering; getting it wrong makes an edit land on a line the caller never saw.
/// </remarks>
/// <exception cref="ArgumentException">
/// Thrown when either bound is not positive, when <paramref name="endLine"/> precedes
/// <paramref name="startLine"/>, or when <paramref name="startLine"/> is past the last line.
/// </exception>
[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. 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. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r, and content ending in a terminator has no extra empty line after it.")]
private async Task<string> ReadLinesAsync(string fileName, int startLine, int? endLine = null, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}

List<string> lines = FileEditor.SliceLines(content, startLine, endLine);

// Each line keeps its terminator, so it doubles as the row separator.
var builder = new StringBuilder();
for (int i = 0; i < lines.Count; i++)
{
builder.Append(startLine + i).Append('\t').Append(lines[i]);
}

return builder.ToString();
}

/// <summary>
/// Delete a file by name.
/// </summary>
Expand Down Expand Up @@ -381,7 +435,7 @@ private async Task<string> ReplaceAsync(string fileName, string oldString, strin
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
[Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -421,6 +475,7 @@ private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit>
- '**' matches across subdirectories, so use \"**/*.md\" to match markdown files at any depth, or \"reports/**\" to restrict the search to the 'reports' subtree.

Returns matching results whose file names are paths relative to the store root (usable with file_access_read), along with snippets and matching lines with line numbers.
Line numbers are 1-based and count lines terminated by \n, \r\n, or a lone \r, and content ending in a terminator has no extra empty line after it.
""")]
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, string? directory = null, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -464,6 +519,7 @@ private AITool[] CreateTools()
var tools = new List<AITool>
{
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadLinesAsync, new AIFunctionFactoryOptions { Name = ReadLinesToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
Comment thread
antsok marked this conversation as resolved.
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ public sealed class FileAccessProviderOptions
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), all tools are exposed. When <see langword="true"/>,
/// only the read-only tools (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>)
/// only the read-only tools (<c>file_access_read</c>, <c>file_access_read_lines</c>, <c>file_access_ls</c>,
/// and <c>file_access_grep</c>)
/// are exposed; the tools that modify the store (<c>file_access_write</c>, <c>file_access_delete</c>,
/// <c>file_access_replace</c>, and <c>file_access_replace_lines</c>) are hidden.
/// </value>
public bool DisableWriteTools { get; set; }

/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the read-only file access tools
/// (<see cref="FileAccessProvider.ReadFileToolName"/>, <see cref="FileAccessProvider.LsToolName"/>,
/// and <see cref="FileAccessProvider.GrepToolName"/>).
/// (<see cref="FileAccessProvider.ReadFileToolName"/>, <see cref="FileAccessProvider.ReadLinesToolName"/>,
/// <see cref="FileAccessProvider.LsToolName"/>, and <see cref="FileAccessProvider.GrepToolName"/>).
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), these tools require approval before invocation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ private async Task<string> WriteAsync(string fileName, string content, string? d
/// <param name="fileName">The name of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")]
[Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
Expand Down Expand Up @@ -360,7 +360,7 @@ private async Task<string> ReplaceAsync(string fileName, string oldString, strin
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
[Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
Expand Down
Loading
Loading