mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
cd512da731
commit
3f77c555cf
+8
-1
@@ -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;
|
||||
|
||||
+194
-24
@@ -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 () =>
|
||||
|
||||
+175
@@ -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
|
||||
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user