From e0b510dce53344ee3a7d0062224e4dbba352d9ef Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:56:47 +0000 Subject: [PATCH 1/2] Align FileAccess with python and improve functionality --- .../Observers/PlanningResponse.cs | 9 +- .../Harness_Step03_DataProcessing/Program.cs | 6 +- .../Harness/FileAccess/FileAccessProvider.cs | 70 ++++-- .../Harness/FileMemory/FileMemoryProvider.cs | 2 +- .../Harness/FileStore/AgentFileStore.cs | 24 +- .../FileStore/FileSystemAgentFileStore.cs | 92 ++++++-- .../FileStore/InMemoryAgentFileStore.cs | 47 +++- .../FileAccess/FileAccessProviderTests.cs | 218 ++++++++++++++++-- .../FileSystemAgentFileStoreTests.cs | 168 ++++++++++++++ .../FileStore/InMemoryAgentFileStoreTests.cs | 104 +++++++++ 10 files changed, 668 insertions(+), 72 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs index 04d6552092a..dfd41f06436 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs @@ -46,6 +46,13 @@ public class PlanningQuestion /// Only used for clarification questions. Null when no predefined choices are offered. /// [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 doesn't directly provide valid inputs for the next turn. + """)] public List? Choices { get; set; } } diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs index 6b88d708f2e..e93fa9c5d5d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -34,10 +34,10 @@ 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 @@ You are a data analyst assistant. You have access to a folder of data files via - 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. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs index f050a8431b0..af4bca62dbe 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs @@ -31,11 +31,12 @@ namespace Microsoft.Agents.AI; /// /// This provider exposes the following tools to the agent: /// -/// SaveFile — Save a file with the given name and content. -/// ReadFile — Read the content of a file by name. -/// DeleteFile — Delete a file by name. -/// ListFiles — List all file names. -/// SearchFiles — Search file contents using a regular expression pattern. +/// file_access_save_file — Save a file with the given name and content. +/// file_access_read_file — Read the content of a file by name. +/// file_access_delete_file — Delete a file by name. +/// file_access_list_files — List the direct child file names in a directory. +/// file_access_list_subdirectories — List the direct child subdirectory names in a directory. +/// file_access_search_files — Recursively search file contents using a regular expression pattern. /// /// /// @@ -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 @@ private async Task DeleteFileAsync(string fileName, CancellationToken ca } /// - /// List all file names. + /// List the direct child file names of a directory. Omit (or pass an empty string) + /// to list the store root. To enumerate files in a subdirectory, pass its relative path. /// + /// The relative directory path to list. Omit or pass an empty string to list the store root. /// A token to cancel the operation. /// A list of file names. - [Description("List all file names.")] - private async Task> 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> ListFilesAsync(string? directory = null, CancellationToken cancellationToken = default) { - IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(string.Empty, cancellationToken).ConfigureAwait(false); + string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory; + IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(target, cancellationToken).ConfigureAwait(false); return new List(fileNames); } /// - /// Search file contents using a regular expression pattern (case-insensitive). + /// List the direct child subdirectory names of a directory. Omit (or pass an empty string) + /// to list the store root. To enumerate subdirectories of a subdirectory, pass its relative path. + /// + /// The relative directory path to list. Omit or pass an empty string to list the store root. + /// A token to cancel the operation. + /// A list of subdirectory names. + [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> ListSubdirectoriesAsync(string? directory = null, CancellationToken cancellationToken = default) + { + string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory; + IReadOnlyList directoryNames = await this._fileStore.ListDirectoriesAsync(target, cancellationToken).ConfigureAwait(false); + return new List(directoryNames); + } + + /// + /// 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. /// /// A regular expression pattern to match against file contents (case-insensitive). - /// An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files. + /// An optional glob pattern to filter which files to search, matched against each file's path relative to the store root. Use ** to match across subdirectories (e.g., "**/*.md"). Leave empty or omit to search all files. /// A token to cancel the operation. - /// A list of search results with matching file names, snippets, and matching lines. - [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.")] + /// A list of search results whose file names are paths relative to the store root. + [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> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) { string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; - IReadOnlyList results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, cancellationToken).ConfigureAwait(false); + IReadOnlyList results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, recursive: true, cancellationToken).ConfigureAwait(false); return new List(results); } @@ -170,11 +199,12 @@ private AITool[] CreateTools() 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 }), ]; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 8394cf5ef87..e70ea3a9e0f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -296,7 +296,7 @@ private async Task> SearchFilesAsync(string regexPattern, { FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; - IReadOnlyList results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false); + IReadOnlyList 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(results.Count); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs index 85b33a4f355..222eca1171b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs @@ -58,6 +58,14 @@ public abstract class AgentFileStore /// A list of file names in the specified directory (direct children only). public abstract Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default); + /// + /// Lists the direct child subdirectories of a directory. + /// + /// The relative path of the directory to list. Use an empty string for the root. + /// A token to cancel the operation. + /// A list of subdirectory names in the specified directory (direct children only). + public abstract Task> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default); + /// /// Checks whether a file exists. /// @@ -76,12 +84,20 @@ public abstract class AgentFileStore /// /// /// An optional glob pattern to filter which files are searched (e.g., "*.md", "research*"). - /// When , all files in the directory are searched. - /// Uses standard glob syntax from . + /// When , all files are searched. + /// Uses standard glob syntax from , matched against each file's path relative to + /// . Use ** to match across subdirectories (e.g., "**/*.md"). + /// + /// + /// When , all descendant files of are searched. + /// When (default), only the direct children of are searched. /// /// A token to cancel the operation. - /// A list of search results with matching file names, snippets, and matching lines. - public abstract Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default); + /// + /// A list of search results. Each result's is the matching file's + /// path relative to . + /// + public abstract Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, bool recursive = false, CancellationToken cancellationToken = default); /// /// Ensures a directory exists, creating it if necessary. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs index a704c9c9e1b..500ec37f76b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs @@ -142,6 +142,7 @@ public override async Task> SearchFilesAsync( string directory, string regexPattern, string? filePattern = null, + bool recursive = false, CancellationToken cancellationToken = default) { string fullDir = this.ResolveSafeDirectoryPath(directory); @@ -156,22 +157,13 @@ public override async Task> SearchFilesAsync( Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null; var results = new List(); - 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 override async Task> SearchFilesAsync( { results.Add(new FileSearchResult { - FileName = fileName, + FileName = relativeName, Snippet = firstSnippet!, MatchingLines = matchingLines, }); @@ -228,6 +220,76 @@ public override async Task> SearchFilesAsync( return results; } + /// + public override Task> ListDirectoriesAsync(string directory, CancellationToken cancellationToken = default) + { + string fullDir = this.ResolveSafeDirectoryPath(directory); + + if (!Directory.Exists(fullDir)) + { + return Task.FromResult>([]); + } + + 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>(directories!); + } + + /// + /// Enumerates the files directly under (or all descendant files when + /// is ), skipping symlinks/reparse points for both + /// files and directories to prevent reading outside the root. + /// + private static IEnumerable EnumerateFiles(string directory, bool recursive) + { + foreach (string filePath in Directory.GetFiles(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.GetDirectories(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; + } + } + } + + /// + /// Returns the path of relative to , + /// normalized to forward-slash separators. Assumes resides under + /// (as produced by ). + /// + 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, '/'); + } + /// public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs index 206a38db8a6..d4a28bbf687 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs @@ -66,6 +66,43 @@ public override Task> ListFilesAsync(string directory, Can return Task.FromResult>(files); } + /// + public override Task> 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(); + var seen = new HashSet(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>(directories); + } + /// public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) { @@ -74,7 +111,7 @@ public override Task FileExistsAsync(string path, CancellationToken cancel } /// - public override Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) + public override Task> 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 override Task> SearchFilesAsync(string di 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; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs index 51b84fdca29..36a0675dec5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs @@ -40,8 +40,8 @@ public async Task ProvideAIContextAsync_ReturnsToolsAsync() // 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 async Task ProvideAIContextAsync_ReturnsInstructionsAsync() // 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 async Task SaveFile_CreatesFileAsync() // 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 async Task SaveFile_DoesNotCreateDescriptionSidecarAsync() // 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 async Task SaveFile_ExistingFile_WithoutOverwrite_ReturnsErrorAsync() // 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 async Task SaveFile_ExistingFile_WithOverwrite_SucceedsAsync() // 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 async Task SaveFile_ReturnsConfirmationAsync() { // 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 async Task ReadFile_ExistingFile_ReturnsContentAsync() 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 async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync() { // 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 async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync() 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 async Task DeleteFile_NonExistent_ReturnsNotFoundAsync() { // 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 async Task ListFiles_ReturnsFileNamesAsync() 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 async Task ListFiles_DoesNotFilterDescriptionFilesAsync() 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 async Task ListFiles_EmptyStore_ReturnsEmptyListAsync() { // 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 async Task ListFiles_EmptyStore_ReturnsEmptyListAsync() 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(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(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(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(result).EnumerateArray().ToList(); + Assert.Empty(entries); + } + #endregion #region SearchFiles Tests @@ -367,7 +459,7 @@ public async Task SearchFiles_FindsMatchingContentAsync() 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 async Task SearchFiles_WithFilePattern_FiltersResultsAsync() 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 async Task SearchFiles_NoMatches_ReturnsEmptyAsync() 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 async Task SearchFiles_NoMatches_ReturnsEmptyAsync() 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(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(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(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 async Task SaveFile_PathTraversal_ThrowsAsync() { // Arrange var tools = await CreateToolsAsync(); - var saveFile = GetTool(tools, "FileAccess_SaveFile"); + var saveFile = GetTool(tools, "file_access_save_file"); // Act & Assert await Assert.ThrowsAsync(async () => @@ -452,7 +622,7 @@ public async Task SaveFile_AbsolutePath_ThrowsAsync() { // Arrange var tools = await CreateToolsAsync(); - var saveFile = GetTool(tools, "FileAccess_SaveFile"); + var saveFile = GetTool(tools, "file_access_save_file"); // Act & Assert await Assert.ThrowsAsync(async () => @@ -468,7 +638,7 @@ public async Task SaveFile_DriveRootedPath_ThrowsAsync() { // Arrange var tools = await CreateToolsAsync(); - var saveFile = GetTool(tools, "FileAccess_SaveFile"); + var saveFile = GetTool(tools, "file_access_save_file"); // Act & Assert await Assert.ThrowsAsync(async () => @@ -485,7 +655,7 @@ public async Task SaveFile_DoubleDotsInFileName_AllowedAsync() // 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 async Task ReadFile_PathTraversal_ThrowsAsync() { // Arrange var tools = await CreateToolsAsync(); - var readFile = GetTool(tools, "FileAccess_ReadFile"); + var readFile = GetTool(tools, "file_access_read_file"); // Act & Assert await Assert.ThrowsAsync(async () => @@ -518,7 +688,7 @@ public async Task DeleteFile_PathTraversal_ThrowsAsync() { // Arrange var tools = await CreateToolsAsync(); - var deleteFile = GetTool(tools, "FileAccess_DeleteFile"); + var deleteFile = GetTool(tools, "file_access_delete_file"); // Act & Assert await Assert.ThrowsAsync(async () => diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs index 32342f177a2..1cc18559c41 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -333,6 +334,100 @@ await Assert.ThrowsAsync(() => 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); + } + #endregion #region Symlink Escape Rejection @@ -802,6 +897,79 @@ public async Task ListFilesAsync_RootWithSymlinkedFile_ExcludesSymlinkAsync() 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 diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs index a6a513017ce..b810000283b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs @@ -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,107 @@ public async Task ListFiles_PathTraversal_ThrowsAsync() // Act & Assert await Assert.ThrowsAsync(() => store.ListFilesAsync("../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); + } } From 3c33a797029126b52266a50cd255784de616c968 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:53:48 +0000 Subject: [PATCH 2/2] Addressing PR comments --- .../Observers/PlanningResponse.cs | 2 +- .../Harness/FileStore/FileSystemAgentFileStore.cs | 4 ++-- .../Harness/FileStore/FileSystemAgentFileStoreTests.cs | 7 +++++++ .../Harness/FileStore/InMemoryAgentFileStoreTests.cs | 10 ++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs index dfd41f06436..00e2f828001 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs @@ -52,7 +52,7 @@ public class PlanningQuestion 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 doesn't directly provide valid inputs for the next turn. + 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? Choices { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs index 500ec37f76b..4de50068f5a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs @@ -246,7 +246,7 @@ public override Task> ListDirectoriesAsync(string director /// private static IEnumerable EnumerateFiles(string directory, bool recursive) { - foreach (string filePath in Directory.GetFiles(directory)) + foreach (string filePath in Directory.EnumerateFiles(directory)) { // Skip files that are symlinks/reparse points. if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0) @@ -262,7 +262,7 @@ private static IEnumerable EnumerateFiles(string directory, bool recursi yield break; } - foreach (string subDir in Directory.GetDirectories(directory)) + 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) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs index 1cc18559c41..b03d5e14ba4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs @@ -428,6 +428,13 @@ public async Task ListDirectoriesAsync_NonExistentDirectory_ReturnsEmptyAsync() Assert.Empty(directories); } + [Fact] + public async Task ListDirectoriesAsync_DotDotSegment_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.ListDirectoriesAsync("../other")); + } + #endregion #region Symlink Escape Rejection diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs index b810000283b..707b61b2fa6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs @@ -522,6 +522,16 @@ public async Task ListFiles_PathTraversal_ThrowsAsync() await Assert.ThrowsAsync(() => store.ListFilesAsync("../other")); } + [Fact] + public async Task ListDirectories_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.ListDirectoriesAsync("../other")); + } + [Fact] public async Task SearchFiles_Recursive_FindsDescendantsAsync() {