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
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 @@ -296,6 +310,47 @@ 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>
/// <remarks>
/// The line numbers agree with the ones <c>file_access_grep</c> reports for the
/// <see cref="AgentFileStore"/> implementations in this package, because both split the content the
/// same way. Grep runs through <see cref="AgentFileStore.SearchAsync"/>, whose contract does not
/// prescribe a split, so a custom store can report numbers that address different lines than this
/// method and <c>file_access_replace_lines</c> do.
/// </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.")]
Comment thread
antsok marked this conversation as resolved.
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 +515,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
76 changes: 74 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,70 @@ 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 the length of <paramref name="line"/> up to but excluding 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 range 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>. This returns a
/// length rather than a trimmed string because the callers scan every line before knowing which ones
/// match, and copying each one would duplicate nearly the whole file on every search.
/// </remarks>
internal static int LineContentLength(string line)
Comment thread
antsok marked this conversation as resolved.
{
if (line.EndsWith("\r\n", StringComparison.Ordinal))
{
return line.Length - 2;
}

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

private static int CountOccurrences(string content, string value)
{
int count = 0;
Expand All @@ -109,7 +174,14 @@ 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 line-edit tools and by the
/// <see cref="AgentFileStore"/> implementations in this package, so for those stores the line numbers
/// reported by <c>grep</c> address the same lines that <c>replace_lines</c> edits. A custom store
/// supplies its own <see cref="AgentFileStore.SearchAsync"/>, whose contract does not require this
/// split, so that alignment does not follow automatically for one.
/// </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,16 @@ 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>
/// For the <see cref="AgentFileStore"/> implementations in this package, 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. A custom store populates
/// this type from its own <see cref="AgentFileStore.SearchAsync"/> and is not held to either property
/// by the base contract.
/// </remarks>
[JsonPropertyName("line")]
public string Line { get; set; } = string.Empty;
}
Loading
Loading