.NET: [BREAKING] Align FileAccess tools with Python; add directory discovery and recursive search (#6474)

* Align FileAccess with python and improve functionality

* Addressing PR comments
This commit is contained in:
westey
2026-06-12 15:28:26 +01:00
committed by GitHub
Unverified
parent cd512da731
commit 3f77c555cf
10 changed files with 685 additions and 72 deletions
@@ -46,6 +46,13 @@ public class PlanningQuestion
/// Only used for clarification questions. Null when no predefined choices are offered.
/// </summary>
[JsonPropertyName("choices")]
[Description("For clarifications, this has a list of options that the user can choose from. null for approvals.")]
[Description("""
For clarifications, this has a list of options that the user can choose from.
null for approvals.
Note: for clarifications, the user will always also be presented with a free form input option, so make sure that each choice provided here is a valid input for the next turn.
E.g. if the question is "Which stock are you referring to?" then valid choices might be ["AAPL", "MSFT", "GOOG"], and the user could also type their own answer.
Invalid choices would be ["Enter tickers directly", "Paste tickers"], since these conflict with the already existing freeform option, and don't directly provide valid inputs for the next turn.
""")]
public List<string>? Choices { get; set; }
}
@@ -34,10 +34,10 @@ using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSource
var instructions =
"""
You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools.
You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools.
## Getting started
- Start by listing available files with FileAccess_ListFiles to see what data is available.
- Start by listing available files with file_access_list_files to see what data is available.
- Read the files to understand their structure and contents.
## Working with data
@@ -46,7 +46,7 @@ var instructions =
- When calculations are needed, work through them step by step and show your reasoning.
## Writing output
- When asked to produce output files (e.g., reports, summaries, filtered data), use FileAccess_SaveFile to write them.
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_save_file to write them.
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
- Confirm what you wrote and where.
@@ -31,11 +31,12 @@ namespace Microsoft.Agents.AI;
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>SaveFile</c> — Save a file with the given name and content.</description></item>
/// <item><description><c>ReadFile</c> — Read the content of a file by name.</description></item>
/// <item><description><c>DeleteFile</c> — Delete a file by name.</description></item>
/// <item><description><c>ListFiles</c> — List all file names.</description></item>
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_access_save_file</c> — Save a file with the given name and content.</description></item>
/// <item><description><c>file_access_read_file</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_access_delete_file</c> — Delete a file by name.</description></item>
/// <item><description><c>file_access_list_files</c> — List the direct child file names in a directory.</description></item>
/// <item><description><c>file_access_list_subdirectories</c> — List the direct child subdirectory names in a directory.</description></item>
/// <item><description><c>file_access_search_files</c> — Recursively search file contents using a regular expression pattern.</description></item>
/// </list>
/// </para>
/// </remarks>
@@ -45,11 +46,13 @@ public sealed class FileAccessProvider : AIContextProvider
private const string DefaultInstructions =
"""
## File Access
You have access to a shared file storage area via the `FileAccess_*` tools for reading, writing, and managing files.
You have access to a shared file storage area via the `file_access_*` tools for reading, writing, and managing files.
These files persist beyond the current session and may be shared across sessions or agents.
Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with.
- Never delete or overwrite existing files unless the user has explicitly asked you to do so.
- Files may be organized into subdirectories. Use `file_access_list_files` and `file_access_list_subdirectories` to explore the tree level by level,
or `file_access_search_files` to search file contents recursively across the whole store.
""";
private readonly AgentFileStore _fileStore;
@@ -137,30 +140,56 @@ public sealed class FileAccessProvider : AIContextProvider
}
/// <summary>
/// List all file names.
/// List the direct child file names of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. To enumerate files in a subdirectory, pass its relative path.
/// </summary>
/// <param name="directory">The relative directory path to list. Omit or pass an empty string to list the store root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file names.</returns>
[Description("List all file names.")]
private async Task<List<string>> ListFilesAsync(CancellationToken cancellationToken = default)
[Description("List the direct child file names of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\".")]
private async Task<List<string>> ListFilesAsync(string? directory = null, CancellationToken cancellationToken = default)
{
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(string.Empty, cancellationToken).ConfigureAwait(false);
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory;
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(target, cancellationToken).ConfigureAwait(false);
return new List<string>(fileNames);
}
/// <summary>
/// Search file contents using a regular expression pattern (case-insensitive).
/// List the direct child subdirectory names of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. To enumerate subdirectories of a subdirectory, pass its relative path.
/// </summary>
/// <param name="directory">The relative directory path to list. Omit or pass an empty string to list the store root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of subdirectory names.</returns>
[Description("List the direct child subdirectory names of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate subdirectories of a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Use this together with file_access_list_files to explore the directory tree level by level.")]
private async Task<List<string>> ListSubdirectoriesAsync(string? directory = null, CancellationToken cancellationToken = default)
{
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory;
IReadOnlyList<string> directoryNames = await this._fileStore.ListDirectoriesAsync(target, cancellationToken).ConfigureAwait(false);
return new List<string>(directoryNames);
}
/// <summary>
/// Search the contents of all files in the store (recursively) using a regular expression pattern (case-insensitive).
/// Optionally filter which files to search using a glob pattern.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="filePattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="filePattern">An optional glob pattern to filter which files to search, matched against each file's path relative to the store root. Use <c>**</c> to match across subdirectories (e.g., "**/*.md"). Leave empty or omit to search all files.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
[Description("Search file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, snippets, and matching lines with line numbers.")]
/// <returns>A list of search results whose file names are paths relative to the store root.</returns>
[Description(
"""
Search the contents of all files in the store (recursively, across all subdirectories) using a regular expression pattern (case-insensitive).
Optionally filter which files to search using a glob pattern matched against each file's path relative to the store root:
- '*' matches within a single path segment
- '**' 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_file), along with snippets and matching lines with line numbers.
""")]
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
{
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, recursive: true, cancellationToken).ConfigureAwait(false);
return new List<FileSearchResult>(results);
}
@@ -170,11 +199,12 @@ public sealed class FileAccessProvider : AIContextProvider
return
[
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SaveFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ReadFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_DeleteFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ListFiles", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SearchFiles", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "file_access_save_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "file_access_read_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "file_access_delete_file", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "file_access_list_files", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ListSubdirectoriesAsync, new AIFunctionFactoryOptions { Name = "file_access_list_subdirectories", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "file_access_search_files", SerializerOptions = serializerOptions }),
];
}
}
@@ -296,7 +296,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, recursive: false, cancellationToken).ConfigureAwait(false);
// Filter out internal files (description sidecars and memory index) so they stay hidden.
var filtered = new List<FileSearchResult>(results.Count);
@@ -58,6 +58,14 @@ public abstract class AgentFileStore
/// <returns>A list of file names in the specified directory (direct children only).</returns>
public abstract Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default);
/// <summary>
/// Lists the direct child subdirectories of a directory.
/// </summary>
/// <param name="directory">The relative path of the directory to list. Use an empty string for the root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of subdirectory names in the specified directory (direct children only).</returns>
public abstract Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether a file exists.
/// </summary>
@@ -76,12 +84,20 @@ public abstract class AgentFileStore
/// </param>
/// <param name="filePattern">
/// An optional glob pattern to filter which files are searched (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// When <see langword="null"/>, all files in the directory are searched.
/// Uses standard glob syntax from <see cref="Matcher"/>.
/// When <see langword="null"/>, all files are searched.
/// Uses standard glob syntax from <see cref="Matcher"/>, matched against each file's path relative to
/// <paramref name="directory"/>. Use <c>**</c> to match across subdirectories (e.g., <c>"**/*.md"</c>).
/// </param>
/// <param name="recursive">
/// When <see langword="true"/>, all descendant files of <paramref name="directory"/> are searched.
/// When <see langword="false"/> (default), only the direct children of <paramref name="directory"/> are searched.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
public abstract Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default);
/// <returns>
/// A list of search results. Each result's <see cref="FileSearchResult.FileName"/> is the matching file's
/// path relative to <paramref name="directory"/>.
/// </returns>
public abstract Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, bool recursive = false, CancellationToken cancellationToken = default);
/// <summary>
/// Ensures a directory exists, creating it if necessary.
@@ -142,6 +142,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
string directory,
string regexPattern,
string? filePattern = null,
bool recursive = false,
CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
@@ -156,22 +157,13 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
var results = new List<FileSearchResult>();
foreach (string filePath in Directory.GetFiles(fullDir))
foreach (string filePath in EnumerateFiles(fullDir, recursive))
{
// Skip files that are symlinks/reparse points to prevent reading outside the root.
if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
// The file path relative to the search directory, using forward slashes.
string relativeName = GetRelativeStorePath(fullDir, filePath);
string? fileName = Path.GetFileName(filePath);
if (fileName is null)
{
continue;
}
// Apply the optional glob filter on the file name.
if (!StorePaths.MatchesGlob(fileName, matcher))
// Apply the optional glob filter on the relative path.
if (!StorePaths.MatchesGlob(relativeName, matcher))
{
continue;
}
@@ -218,7 +210,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
{
results.Add(new FileSearchResult
{
FileName = fileName,
FileName = relativeName,
Snippet = firstSnippet!,
MatchingLines = matchingLines,
});
@@ -228,6 +220,76 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
return results;
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
var directories = Directory.GetDirectories(fullDir)
.Where(d => (File.GetAttributes(d) & FileAttributes.ReparsePoint) == 0)
.Select(Path.GetFileName)
.Where(name => name is not null)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(directories!);
}
/// <summary>
/// Enumerates the files directly under <paramref name="directory"/> (or all descendant files when
/// <paramref name="recursive"/> is <see langword="true"/>), skipping symlinks/reparse points for both
/// files and directories to prevent reading outside the root.
/// </summary>
private static IEnumerable<string> EnumerateFiles(string directory, bool recursive)
{
foreach (string filePath in Directory.EnumerateFiles(directory))
{
// Skip files that are symlinks/reparse points.
if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
yield return filePath;
}
if (!recursive)
{
yield break;
}
foreach (string subDir in Directory.EnumerateDirectories(directory))
{
// Skip symlinked/reparse-point directories so recursion cannot escape the root.
if ((File.GetAttributes(subDir) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
foreach (string filePath in EnumerateFiles(subDir, recursive: true))
{
yield return filePath;
}
}
}
/// <summary>
/// Returns the path of <paramref name="filePath"/> relative to <paramref name="baseDirectory"/>,
/// normalized to forward-slash separators. Assumes <paramref name="filePath"/> resides under
/// <paramref name="baseDirectory"/> (as produced by <see cref="EnumerateFiles"/>).
/// </summary>
private static string GetRelativeStorePath(string baseDirectory, string filePath)
{
string baseTrimmed = baseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string relative = filePath.Substring(baseTrimmed.Length)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return relative.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
}
/// <inheritdoc />
public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
@@ -66,6 +66,43 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
return Task.FromResult<IReadOnlyList<string>>(files);
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default)
{
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
}
// A subdirectory is the first path segment of any key whose remainder (after the prefix)
// still contains a separator. Collect distinct first segments, preserving original casing.
var directories = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string key in this._files.Keys)
{
if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string remainder = key.Substring(prefix.Length);
int separatorIndex = remainder.IndexOf("/", StringComparison.Ordinal);
if (separatorIndex <= 0)
{
continue;
}
string segment = remainder.Substring(0, separatorIndex);
if (seen.Add(segment))
{
directories.Add(segment);
}
}
return Task.FromResult<IReadOnlyList<string>>(directories);
}
/// <inheritdoc />
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
{
@@ -74,7 +111,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
}
/// <inheritdoc />
public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, bool recursive = false, CancellationToken cancellationToken = default)
{
// Normalize the directory prefix for path matching.
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
@@ -96,14 +133,16 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
continue;
}
// Exclude files in subdirectories (direct children only).
// The file path relative to the search directory.
string relativeName = kvp.Key.Substring(prefix.Length);
if (relativeName.IndexOf("/", StringComparison.Ordinal) >= 0)
// When not recursive, exclude files in subdirectories (direct children only).
if (!recursive && relativeName.IndexOf("/", StringComparison.Ordinal) >= 0)
{
continue;
}
// Apply the optional glob filter on the file name.
// Apply the optional glob filter on the relative path.
if (!StorePaths.MatchesGlob(relativeName, matcher))
{
continue;
@@ -40,8 +40,8 @@ public class FileAccessProviderTests
// Arrange
var tools = await CreateToolsAsync();
// Assert — 5 tools: SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles
Assert.Equal(5, tools.Count());
// Assert — 6 tools: SaveFile, ReadFile, DeleteFile, ListFiles, ListSubdirectories, SearchFiles
Assert.Equal(6, tools.Count());
}
[Fact]
@@ -61,7 +61,7 @@ public class FileAccessProviderTests
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("File Access", result.Instructions);
Assert.Contains("FileAccess_", result.Instructions);
Assert.Contains("file_access_", result.Instructions);
Assert.Contains("persist beyond the current session", result.Instructions);
}
@@ -108,7 +108,7 @@ public class FileAccessProviderTests
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
@@ -128,7 +128,7 @@ public class FileAccessProviderTests
// Arrange — FileAccessProvider should never create description sidecar files.
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
@@ -148,7 +148,7 @@ public class FileAccessProviderTests
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
@@ -175,7 +175,7 @@ public class FileAccessProviderTests
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
@@ -200,7 +200,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act
var result = await InvokeToolAsync(saveFile, new AIFunctionArguments
@@ -225,7 +225,7 @@ public class FileAccessProviderTests
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Stored content");
var tools = await CreateToolsAsync(store);
var readFile = GetTool(tools, "FileAccess_ReadFile");
var readFile = GetTool(tools, "file_access_read_file");
// Act
var result = await InvokeToolAsync(readFile, new AIFunctionArguments
@@ -243,7 +243,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var readFile = GetTool(tools, "FileAccess_ReadFile");
var readFile = GetTool(tools, "file_access_read_file");
// Act
var result = await InvokeToolAsync(readFile, new AIFunctionArguments
@@ -267,7 +267,7 @@ public class FileAccessProviderTests
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Content");
var tools = await CreateToolsAsync(store);
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
var deleteFile = GetTool(tools, "file_access_delete_file");
// Act
var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments
@@ -286,7 +286,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
var deleteFile = GetTool(tools, "file_access_delete_file");
// Act
var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments
@@ -311,7 +311,7 @@ public class FileAccessProviderTests
await store.WriteFileAsync("notes.md", "Content");
await store.WriteFileAsync("data.txt", "Data");
var tools = await CreateToolsAsync(store);
var listFiles = GetTool(tools, "FileAccess_ListFiles");
var listFiles = GetTool(tools, "file_access_list_files");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
@@ -331,7 +331,7 @@ public class FileAccessProviderTests
await store.WriteFileAsync("notes.md", "Content");
await store.WriteFileAsync("notes_description.md", "Description");
var tools = await CreateToolsAsync(store);
var listFiles = GetTool(tools, "FileAccess_ListFiles");
var listFiles = GetTool(tools, "file_access_list_files");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
@@ -346,7 +346,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var listFiles = GetTool(tools, "FileAccess_ListFiles");
var listFiles = GetTool(tools, "file_access_list_files");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
@@ -356,6 +356,98 @@ public class FileAccessProviderTests
Assert.Empty(entries);
}
[Fact]
public async Task ListFiles_WithDirectory_ListsSubdirectoryChildrenAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.txt", "Root");
await store.WriteFileAsync("reports/2024/q1.md", "Q1");
await store.WriteFileAsync("reports/2024/q2.md", "Q2");
var tools = await CreateToolsAsync(store);
var listFiles = GetTool(tools, "file_access_list_files");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments
{
["directory"] = "reports/2024",
});
// Assert — only the direct children of reports/2024 are returned (by their names)
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, e => e.GetString() == "q1.md");
Assert.Contains(entries, e => e.GetString() == "q2.md");
}
#endregion
#region ListSubdirectories Tests
[Fact]
public async Task ListSubdirectories_ReturnsDirectChildDirectoriesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.txt", "Root");
await store.WriteFileAsync("reports/q1.md", "Q1");
await store.WriteFileAsync("reports/2024/q2.md", "Q2");
await store.WriteFileAsync("data/raw.csv", "x");
var tools = await CreateToolsAsync(store);
var listSubdirectories = GetTool(tools, "file_access_list_subdirectories");
// Act — list the root's direct child subdirectories
var result = await InvokeToolAsync(listSubdirectories, new AIFunctionArguments());
// Assert — only direct children (reports, data); not the nested 2024
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(2, entries.Count);
Assert.Contains("reports", entries);
Assert.Contains("data", entries);
}
[Fact]
public async Task ListSubdirectories_WithDirectory_ListsNestedChildrenAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("reports/q1.md", "Q1");
await store.WriteFileAsync("reports/2024/q2.md", "Q2");
await store.WriteFileAsync("reports/2025/q3.md", "Q3");
var tools = await CreateToolsAsync(store);
var listSubdirectories = GetTool(tools, "file_access_list_subdirectories");
// Act
var result = await InvokeToolAsync(listSubdirectories, new AIFunctionArguments
{
["directory"] = "reports",
});
// Assert — direct child subdirectories of reports
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(2, entries.Count);
Assert.Contains("2024", entries);
Assert.Contains("2025", entries);
}
[Fact]
public async Task ListSubdirectories_NoSubdirectories_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("a.txt", "A");
await store.WriteFileAsync("b.txt", "B");
var tools = await CreateToolsAsync(store);
var listSubdirectories = GetTool(tools, "file_access_list_subdirectories");
// Act
var result = await InvokeToolAsync(listSubdirectories, new AIFunctionArguments());
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Empty(entries);
}
#endregion
#region SearchFiles Tests
@@ -367,7 +459,7 @@ public class FileAccessProviderTests
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Important research findings about AI");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
var searchFiles = GetTool(tools, "file_access_search_files");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
@@ -392,7 +484,7 @@ public class FileAccessProviderTests
await store.WriteFileAsync("notes.md", "Important data");
await store.WriteFileAsync("data.txt", "Important data");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
var searchFiles = GetTool(tools, "file_access_search_files");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
@@ -414,7 +506,7 @@ public class FileAccessProviderTests
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "No matching content here");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
var searchFiles = GetTool(tools, "file_access_search_files");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
@@ -427,6 +519,84 @@ public class FileAccessProviderTests
Assert.Empty(entries);
}
[Fact]
public async Task SearchFiles_SearchesAllDescendantsRecursivelyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.md", "Important data at root");
await store.WriteFileAsync("reports/q1.md", "Important data in reports");
await store.WriteFileAsync("reports/2024/q2.md", "Important data nested deeper");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "file_access_search_files");
// Act — no glob, so all descendants are searched
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "Important",
});
// Assert — matches at every depth, returned as store-root-relative paths
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
var names = entries.ConvertAll(e => e.GetProperty("fileName").GetString());
Assert.Equal(3, names.Count);
Assert.Contains("root.md", names);
Assert.Contains("reports/q1.md", names);
Assert.Contains("reports/2024/q2.md", names);
}
[Fact]
public async Task SearchFiles_GlobScopesToSubtreeAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.md", "Important data at root");
await store.WriteFileAsync("reports/q1.md", "Important data in reports");
await store.WriteFileAsync("reports/2024/q2.md", "Important data nested deeper");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "file_access_search_files");
// Act — restrict to the reports subtree using a recursive glob
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "Important",
["filePattern"] = "reports/**",
});
// Assert — only the files under reports/ match
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
var names = entries.ConvertAll(e => e.GetProperty("fileName").GetString());
Assert.Equal(2, names.Count);
Assert.Contains("reports/q1.md", names);
Assert.Contains("reports/2024/q2.md", names);
}
[Fact]
public async Task SearchFiles_RecursiveGlobMatchesNestedExtensionAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Important data");
await store.WriteFileAsync("data/raw.txt", "Important data");
await store.WriteFileAsync("reports/2024/q1.md", "Important data");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "file_access_search_files");
// Act — match markdown files at any depth
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "Important",
["filePattern"] = "**/*.md",
});
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
var names = entries.ConvertAll(e => e.GetProperty("fileName").GetString());
Assert.Equal(2, names.Count);
Assert.Contains("notes.md", names);
Assert.Contains("reports/2024/q1.md", names);
}
#endregion
#region Path Traversal Protection
@@ -436,7 +606,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
@@ -452,7 +622,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
@@ -468,7 +638,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
@@ -485,7 +655,7 @@ public class FileAccessProviderTests
// Arrange — "notes..md" is not a path traversal attempt.
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
var saveFile = GetTool(tools, "file_access_save_file");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
@@ -503,7 +673,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var readFile = GetTool(tools, "FileAccess_ReadFile");
var readFile = GetTool(tools, "file_access_read_file");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
@@ -518,7 +688,7 @@ public class FileAccessProviderTests
{
// Arrange
var tools = await CreateToolsAsync();
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
var deleteFile = GetTool(tools, "file_access_delete_file");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
@@ -2,6 +2,7 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -333,6 +334,107 @@ public sealed class FileSystemAgentFileStoreTests : IDisposable
this._store.SearchFilesAsync("", "(a+)+$"));
}
[Fact]
public async Task SearchFilesAsync_Recursive_FindsDescendantsAsync()
{
// Arrange
await this._store.WriteFileAsync("notes.md", "Match here");
await this._store.WriteFileAsync("reports/q1.md", "Match here too");
await this._store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await this._store.SearchFilesAsync("", "Match", filePattern: null, recursive: true);
// Assert
Assert.Equal(3, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFilesAsync_Recursive_GlobScopesToSubtreeAsync()
{
// Arrange
await this._store.WriteFileAsync("notes.md", "Match here");
await this._store.WriteFileAsync("reports/q1.md", "Match here too");
await this._store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await this._store.SearchFilesAsync("", "Match", filePattern: "reports/**", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFilesAsync_Recursive_GlobMatchesNestedExtensionAsync()
{
// Arrange
await this._store.WriteFileAsync("notes.md", "Match here");
await this._store.WriteFileAsync("reports/q1.txt", "Match here too");
await this._store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await this._store.SearchFilesAsync("", "Match", filePattern: "**/*.md", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md", names);
}
[Fact]
public async Task ListDirectoriesAsync_ReturnsDirectChildSubdirectoriesAsync()
{
// Arrange
await this._store.WriteFileAsync("root.md", "x");
await this._store.WriteFileAsync("reports/q1.md", "x");
await this._store.WriteFileAsync("reports/2024/q2.md", "x");
await this._store.WriteFileAsync("images/logo.txt", "x");
// Act
var directories = await this._store.ListDirectoriesAsync("");
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("images,reports", sorted);
}
[Fact]
public async Task ListDirectoriesAsync_NestedDirectory_ReturnsChildrenAsync()
{
// Arrange
await this._store.WriteFileAsync("reports/q1.md", "x");
await this._store.WriteFileAsync("reports/2024/q2.md", "x");
await this._store.WriteFileAsync("reports/2025/q3.md", "x");
// Act
var directories = await this._store.ListDirectoriesAsync("reports");
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("2024,2025", sorted);
}
[Fact]
public async Task ListDirectoriesAsync_NonExistentDirectory_ReturnsEmptyAsync()
{
// Act
var directories = await this._store.ListDirectoriesAsync("no-dir");
// Assert
Assert.Empty(directories);
}
[Fact]
public async Task ListDirectoriesAsync_DotDotSegment_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ListDirectoriesAsync("../other"));
}
#endregion
#region Symlink Escape Rejection
@@ -802,6 +904,79 @@ public sealed class FileSystemAgentFileStoreTests : IDisposable
File.Delete(outsideFile);
}
}
[Fact]
public async Task SearchFilesAsync_Recursive_SkipsSymlinkedSubdirectoryAsync()
{
// Arrange — a symlinked directory under root should be skipped by recursive search.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_recursive_target_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "leak.txt"), "RECURSIVE_SECRET_CONTENT");
string linkDir = Path.Combine(this._rootDir, "linked-sub");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
await this._store.WriteFileAsync("normal/visible.txt", "RECURSIVE_VISIBLE_CONTENT");
// Act — recursive search should not descend into the symlinked directory.
var results = await this._store.SearchFilesAsync("", "RECURSIVE", filePattern: null, recursive: true);
// Assert — only the non-symlinked file is found.
Assert.Single(results);
Assert.Equal("normal/visible.txt", results[0].FileName);
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
[Fact]
public async Task ListDirectoriesAsync_ExcludesSymlinkedDirectoryAsync()
{
// Arrange — a symlinked directory under root should not be listed.
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_listdir_target_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideDir);
string linkDir = Path.Combine(this._rootDir, "linked-listing");
try
{
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
{
return;
}
await this._store.WriteFileAsync("real-dir/file.txt", "x");
// Act
var directories = await this._store.ListDirectoriesAsync("");
// Assert — the symlinked directory is excluded, the real one is present.
Assert.DoesNotContain("linked-listing", directories);
Assert.Contains("real-dir", directories);
}
finally
{
if (Directory.Exists(linkDir))
{
Directory.Delete(linkDir);
}
Directory.Delete(outsideDir, recursive: true);
}
}
#endif
#endregion
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
@@ -520,4 +521,117 @@ public class InMemoryAgentFileStoreTests
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.ListFilesAsync("../other"));
}
[Fact]
public async Task ListDirectories_PathTraversal_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.ListDirectoriesAsync("../other"));
}
[Fact]
public async Task SearchFiles_Recursive_FindsDescendantsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Match here");
await store.WriteFileAsync("reports/q1.md", "Match here too");
await store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchFilesAsync("", "Match", filePattern: null, recursive: true);
// Assert
Assert.Equal(3, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFiles_Recursive_GlobScopesToSubtreeAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Match here");
await store.WriteFileAsync("reports/q1.md", "Match here too");
await store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchFilesAsync("", "Match", filePattern: "reports/**", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFiles_Recursive_GlobMatchesNestedExtensionAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Match here");
await store.WriteFileAsync("reports/q1.txt", "Match here too");
await store.WriteFileAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchFilesAsync("", "Match", filePattern: "**/*.md", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md", names);
}
[Fact]
public async Task ListDirectories_ReturnsDirectChildSubdirectoriesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.md", "x");
await store.WriteFileAsync("reports/q1.md", "x");
await store.WriteFileAsync("reports/2024/q2.md", "x");
await store.WriteFileAsync("images/logo.png", "x");
// Act
var directories = await store.ListDirectoriesAsync("");
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("images,reports", sorted);
}
[Fact]
public async Task ListDirectories_NestedDirectory_ReturnsChildrenAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("reports/q1.md", "x");
await store.WriteFileAsync("reports/2024/q2.md", "x");
await store.WriteFileAsync("reports/2025/q3.md", "x");
// Act
var directories = await store.ListDirectoriesAsync("reports");
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("2024,2025", sorted);
}
[Fact]
public async Task ListDirectories_NoSubdirectories_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("root.md", "x");
// Act
var directories = await store.ListDirectoriesAsync("");
// Assert
Assert.Empty(directories);
}
}