Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -296,6 +310,40 @@ private async Task<string> ReadAsync(string fileName, CancellationToken cancella
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>
/// <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. 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)
{
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 @@ -460,6 +508,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),
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
70 changes: 68 additions & 2 deletions dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace Microsoft.Agents.AI;

/// <summary>
/// Internal helpers shared by <see cref="FileAccessProvider"/> and <see cref="FileMemoryProvider"/>
/// for the <c>replace</c> and <c>replace_lines</c> tools.
/// for the <c>replace</c>, <c>replace_lines</c>, and <c>read_lines</c> tools, and by the file stores
/// for <c>grep</c>.
/// </summary>
internal static class FileEditor
{
Expand Down Expand Up @@ -92,6 +93,67 @@ internal static string ApplyReplaceLines(string content, IReadOnlyList<FileLineE
return string.Concat(lines);
}

/// <summary>
/// Returns the 1-based inclusive <c>[startLine, endLine]</c> slice of <paramref name="content"/>,
/// with each line's terminator kept attached. An <paramref name="endLine"/> past the last line is
/// clamped, and omitting it reads to the end of the content.
/// </summary>
/// <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>
internal static List<string> SliceLines(string content, int startLine, int? endLine)
{
List<string> lines = SplitLinesKeepEnds(content);
int total = lines.Count;

// These messages reach the model as the tool's failure text, so they name the arguments as the
// generated schema exposes them (startLine/endLine), not in snake_case.
if (startLine < 1)
{
throw new ArgumentException($"startLine must be a positive integer, got {startLine}.");
}

if (endLine is < 1)
{
throw new ArgumentException($"endLine must be a positive integer, got {endLine}.");
}

if (endLine < startLine)
{
throw new ArgumentException($"endLine ({endLine}) must not be less than startLine ({startLine}).");
}

if (startLine > total)
{
throw new ArgumentException($"startLine {startLine} is out of range (file has {total} lines).");
}

// Clamping end_line rather than failing keeps "read from here to the end" a single call.
int lastLine = endLine is null ? total : Math.Min(endLine.Value, total);
return lines.GetRange(startLine - 1, lastLine - startLine + 1);
}

/// <summary>
/// Returns <paramref name="line"/> without the <c>\r\n</c>, <c>\n</c>, or lone <c>\r</c> that
/// terminates it, so search patterns are matched against a line's text rather than its line break.
/// </summary>
/// <remarks>
/// Leaving any part of the terminator in place would make an end-anchored pattern such as
/// <c>match$</c> fail on a CRLF or lone-CR line whose text is exactly <c>match</c>.
/// </remarks>
internal static string TrimLineTerminator(string line)
{
if (line.EndsWith("\r\n", StringComparison.Ordinal))
{
return line.Substring(0, line.Length - 2);
}

return line.EndsWith("\n", StringComparison.Ordinal) || line.EndsWith("\r", StringComparison.Ordinal)
? line.Substring(0, line.Length - 1)
: line;
}

private static int CountOccurrences(string content, string value)
{
int count = 0;
Expand All @@ -109,7 +171,11 @@ private static int CountOccurrences(string content, string value)
/// Splits content into lines, keeping each line's trailing newline (<c>\r\n</c>, <c>\n</c>, or a lone
/// <c>\r</c>) attached. The final line has no terminator when the content does not end with a newline.
/// </summary>
private static List<string> SplitLinesKeepEnds(string content)
/// <remarks>
/// This is the single definition of a "line" shared by the search and line-edit tools, so the line
/// numbers reported by <c>grep</c> address the same lines that <c>replace_lines</c> edits.
/// </remarks>
internal static List<string> SplitLinesKeepEnds(string content)
{
var lines = new List<string>();
int start = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ public sealed class FileSearchMatch
public int LineNumber { get; set; }

/// <summary>
/// Gets or sets the content of the matching line.
/// Gets or sets the matching line, verbatim.
/// </summary>
/// <remarks>
/// The line keeps its own terminator (<c>\r\n</c>, <c>\n</c>, or a lone <c>\r</c>), except on a final
/// line that the content does not terminate. Together with <see cref="LineNumber"/> addressing the
/// same lines the line-edit tools use, this makes the value reusable as a literal replacement line.
/// </remarks>
[JsonPropertyName("line")]
public string Line { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,19 @@ public override async Task<IReadOnlyList<FileSearchResult>> SearchAsync(
#endif

// Search each line for regex matches, tracking line numbers and building a snippet.
string[] lines = fileContent.Split('\n');
// 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);
var matchingLines = new List<FileSearchMatch>();
string? firstSnippet = null;
int lineStartOffset = 0;

for (int i = 0; i < lines.Length; i++)
for (int i = 0; i < lines.Count; i++)
{
Match match = regex.Match(lines[i]);
Match match = regex.Match(FileEditor.TrimLineTerminator(lines[i]));
if (match.Success)
{
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i] });

// Build a context snippet around the first match (±50 chars).
if (firstSnippet is null)
Expand All @@ -226,8 +228,8 @@ public override async Task<IReadOnlyList<FileSearchResult>> SearchAsync(
}
}

// Advance the offset past this line (including the '\n' separator).
lineStartOffset += lines[i].Length + 1;
// Advance the offset past this line; its terminator is already part of its length.
lineStartOffset += lines[i].Length;
}

if (matchingLines.Count > 0)
Expand Down
Loading
Loading