mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add a file memory provider (#5315)
* Add a file memory provider * Address PR comments * Fix review comments. * Add additional unit tests * Addressing PR comments.
This commit is contained in:
committed by
GitHub
Unverified
parent
99627e41d2
commit
8dca006edd
@@ -75,6 +75,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
|
||||
@@ -41,6 +41,13 @@ public static class ToolCallFormatter
|
||||
"ContinueTask" => FormatContinueTask(call),
|
||||
"ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
|
||||
// File memory tools
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_ListFiles" => null,
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
|
||||
// External tools
|
||||
"web_search" => FormatStringArg(call, "query"),
|
||||
"DownloadUri" => FormatStringArg(call, "uri"),
|
||||
@@ -152,6 +159,36 @@ public static class ToolCallFormatter
|
||||
: $"(task #{taskId.Value})";
|
||||
}
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetString(call, "fileName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"({fileName})"
|
||||
: $"({fileName}, with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetString(call, "regexPattern");
|
||||
string? filePattern = GetString(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetString(call, paramName);
|
||||
|
||||
@@ -90,7 +90,7 @@ AIAgent agent = new ChatClientAgent(
|
||||
{
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
AIContextProviders = [new TodoProvider(), new AgentModeProvider()],
|
||||
AIContextProviders = [new TodoProvider(), new AgentModeProvider(), new FileMemoryProvider(new InMemoryAgentFileStore())],
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
|
||||
@@ -81,6 +81,15 @@ internal static partial class AgentJsonUtilities
|
||||
// AgentModeProvider types
|
||||
[JsonSerializable(typeof(AgentModeState))]
|
||||
|
||||
// FileMemoryProvider types
|
||||
[JsonSerializable(typeof(FileMemoryState))]
|
||||
[JsonSerializable(typeof(FileSearchResult))]
|
||||
[JsonSerializable(typeof(List<FileSearchResult>), TypeInfoPropertyName = "FileSearchResultList")]
|
||||
[JsonSerializable(typeof(FileSearchMatch))]
|
||||
[JsonSerializable(typeof(List<FileSearchMatch>), TypeInfoPropertyName = "FileSearchMatchList")]
|
||||
[JsonSerializable(typeof(FileListEntry))]
|
||||
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for file storage operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All paths are relative to an implementation-defined root. Implementations may map these
|
||||
/// paths to a local file system, in-memory store, remote blob storage, or other mechanisms.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Paths use forward slashes as separators and must not escape the root (e.g., via <c>..</c> segments).
|
||||
/// It is up to each implementation to ensure that this is enforced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class AgentFileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes content to a file, creating or overwriting it.
|
||||
/// </summary>
|
||||
/// <param name="path">The relative path of the file to write.</param>
|
||||
/// <param name="content">The content to write to the file.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public abstract Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the content of a file.
|
||||
/// </summary>
|
||||
/// <param name="path">The relative path of the file to read.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The file content, or <see langword="null"/> if the file does not exist.</returns>
|
||||
public abstract Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file.
|
||||
/// </summary>
|
||||
/// <param name="path">The relative path of the file to delete.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the file was deleted; <see langword="false"/> if it did not exist.</returns>
|
||||
public abstract Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists files in 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 file names in the specified directory (direct children only).</returns>
|
||||
public abstract Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a file exists.
|
||||
/// </summary>
|
||||
/// <param name="path">The relative path of the file to check.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the file exists; otherwise, <see langword="false"/>.</returns>
|
||||
public abstract Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for files whose content matches a regular expression pattern.
|
||||
/// </summary>
|
||||
/// <param name="directory">The relative path of the directory to search. Use an empty string for the root.</param>
|
||||
/// <param name="regexPattern">
|
||||
/// A regular expression pattern to match against file contents. The pattern is matched case-insensitively.
|
||||
/// For example, <c>"error|warning"</c> matches lines containing "error" or "warning".
|
||||
/// </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"/>.
|
||||
/// </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);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a directory exists, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <param name="path">The relative path of the directory to create.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Matcher"/> for the specified glob pattern. Use the returned instance
|
||||
/// to test multiple file names without allocating a new matcher for each one.
|
||||
/// </summary>
|
||||
/// <param name="filePattern">
|
||||
/// The glob pattern to match against (e.g., <c>"*.md"</c>, <c>"research*"</c>).
|
||||
/// </param>
|
||||
/// <returns>A <see cref="Matcher"/> configured with the specified pattern.</returns>
|
||||
protected static Matcher CreateGlobMatcher(string filePattern)
|
||||
{
|
||||
var matcher = new Matcher(System.StringComparison.OrdinalIgnoreCase);
|
||||
matcher.AddInclude(filePattern);
|
||||
return matcher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a file name matches a pre-built glob <see cref="Matcher"/>.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to test (not a full path — just the name).</param>
|
||||
/// <param name="matcher">
|
||||
/// A pre-built <see cref="Matcher"/> to test against.
|
||||
/// When <see langword="null"/>, this method returns <see langword="true"/> for any file name.
|
||||
/// </param>
|
||||
/// <returns><see langword="true"/> if the file name matches the pattern or if the matcher is <see langword="null"/>; otherwise, <see langword="false"/>.</returns>
|
||||
protected static bool MatchesGlob(string fileName, Matcher? matcher)
|
||||
{
|
||||
if (matcher is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
PatternMatchingResult result = matcher.Match(fileName);
|
||||
return result.HasMatches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file entry returned by the <see cref="FileMemoryProvider"/> list files tool,
|
||||
/// containing the file name and an optional description.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileListEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileName")]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description of the file, or <see langword="null"/> if no description is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that provides file-based memory tools to an agent
|
||||
/// for storing, retrieving, modifying, listing, deleting, and searching files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="FileMemoryProvider"/> enables agents to persist information across interactions
|
||||
/// using a file-based storage model. Each memory is stored as an individual file with a meaningful name.
|
||||
/// For large files, a companion description file (suffixed with <c>_description.md</c>) can be stored
|
||||
/// alongside the main file to provide a summary.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// File access is mediated through a <see cref="AgentFileStore"/> abstraction, allowing pluggable
|
||||
/// backends (in-memory, local file system, remote blob storage, etc.).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>SaveFile</c> — Save a memory file with the given name, content, and an optional description.</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 files with their descriptions (if available).</description></item>
|
||||
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileMemoryProvider : AIContextProvider
|
||||
{
|
||||
private const string DescriptionSuffix = "_description.md";
|
||||
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
You have access to a file-based memory system via the FileMemory_* tools for storing and retrieving information across interactions.
|
||||
Use FileMemory_SaveFile to store one memory per file with a clear, descriptive file name (e.g., "projectarchitecture.md", "userpreferences.md").
|
||||
For large files, include a description when saving to provide a summary that helps with discovery.
|
||||
Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories.
|
||||
Use FileMemory_ReadFile to retrieve file contents and FileMemory_DeleteFile to remove outdated memories.
|
||||
Keep memories up-to-date by overwriting files when information changes.
|
||||
When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
|
||||
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
|
||||
This ensures important data remains accessible across long-running sessions.
|
||||
""";
|
||||
|
||||
private readonly AgentFileStore _fileStore;
|
||||
private readonly ProviderSessionState<FileMemoryState> _sessionState;
|
||||
private IReadOnlyList<string>? _stateKeys;
|
||||
private AITool[]? _tools;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileMemoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileStore">The file store implementation used for storage operations.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// An optional function that initializes the <see cref="FileMemoryState"/> for a new session.
|
||||
/// Use this to customize the working folder (e.g., per-user or per-session subfolders).
|
||||
/// When <see langword="null"/>, the default initializer creates state with an empty working folder.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStore"/> is <see langword="null"/>.</exception>
|
||||
public FileMemoryProvider(AgentFileStore fileStore, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
|
||||
{
|
||||
Throw.IfNull(fileStore);
|
||||
|
||||
this._fileStore = fileStore;
|
||||
this._sessionState = new ProviderSessionState<FileMemoryState>(
|
||||
stateInitializer ?? (_ => new FileMemoryState()),
|
||||
this.GetType().Name,
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Ensure the working folder exists in the store.
|
||||
if (!string.IsNullOrEmpty(state.WorkingFolder))
|
||||
{
|
||||
await this._fileStore.CreateDirectoryAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = DefaultInstructions,
|
||||
Tools = this._tools ??= this.CreateTools(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a memory file with the given name and content.
|
||||
/// Overwrites the file if it already exists.
|
||||
/// Include a description for large files to provide a summary that helps with discovery.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to save.</param>
|
||||
/// <param name="content">The content to write to the file.</param>
|
||||
/// <param name="description">An optional description of the file contents for discovery. Leave empty or omit to skip.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A confirmation message.</returns>
|
||||
[Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")]
|
||||
private async Task<string> SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove any stale description file when no description is provided.
|
||||
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(description)
|
||||
? $"File '{fileName}' saved."
|
||||
: $"File '{fileName}' saved with description.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the content of a memory file by name.
|
||||
/// Returns the file content or a message indicating the file was not found.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to read.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The file content or a not-found message.</returns>
|
||||
[Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")]
|
||||
private async Task<string> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return content ?? $"File '{fileName}' not found.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a memory file by name. Also removes its companion description file if one exists.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to delete.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A confirmation or not-found message.</returns>
|
||||
[Description("Delete a memory file by name. Also removes its companion description file if one exists.")]
|
||||
private async Task<string> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Also delete companion description file if it exists.
|
||||
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
|
||||
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all memory files with their descriptions (if available). Description files are not shown separately.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A list of file entries with names and optional descriptions.</returns>
|
||||
[Description("List all memory files with their descriptions (if available). Description files are not shown separately.")]
|
||||
private async Task<List<FileListEntry>> ListFilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var descriptionFileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string file in fileNames)
|
||||
{
|
||||
if (file.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptionFileSet.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = new List<FileListEntry>();
|
||||
foreach (string file in fileNames)
|
||||
{
|
||||
if (descriptionFileSet.Contains(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? fileDescription = null;
|
||||
string descFileName = GetDescriptionFileName(file);
|
||||
|
||||
if (descriptionFileSet.Contains(descFileName))
|
||||
{
|
||||
string descPath = CombinePaths(state.WorkingFolder, descFileName);
|
||||
fileDescription = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
entries.Add(new FileListEntry { FileName = file, Description = fileDescription });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search memory file contents using a regular expression pattern (case-insensitive).
|
||||
/// Optionally filter which files to search using a glob pattern.
|
||||
/// Returns matching file names, content snippets, and matching lines with line numbers.
|
||||
/// </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="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 memory 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, content snippets, and matching lines with line numbers.")]
|
||||
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
return new List<FileSearchResult>(results);
|
||||
}
|
||||
|
||||
private AITool[] CreateTools()
|
||||
{
|
||||
var serializerOptions = AgentJsonUtilities.DefaultOptions;
|
||||
|
||||
return
|
||||
[
|
||||
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SaveFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ReadFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_DeleteFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ListFiles", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SearchFiles", SerializerOptions = serializerOptions }),
|
||||
];
|
||||
}
|
||||
|
||||
private static string GetDescriptionFileName(string fileName)
|
||||
{
|
||||
int extIndex = fileName.LastIndexOf('.');
|
||||
if (extIndex > 0)
|
||||
{
|
||||
#pragma warning disable CA1845 // Use span-based 'string.Concat' — not available on all target frameworks
|
||||
return fileName.Substring(0, extIndex) + DescriptionSuffix;
|
||||
#pragma warning restore CA1845
|
||||
}
|
||||
|
||||
return fileName + DescriptionSuffix;
|
||||
}
|
||||
|
||||
private static string ResolvePath(string workingFolder, string fileName)
|
||||
{
|
||||
// Prevent path traversal by rejecting rooted paths and '.'/'..' segments.
|
||||
string normalized = fileName.Replace('\\', '/');
|
||||
|
||||
if (Path.IsPathRooted(fileName) ||
|
||||
fileName.StartsWith("/", StringComparison.Ordinal) ||
|
||||
fileName.StartsWith("\\", StringComparison.Ordinal) ||
|
||||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid file name: '{fileName}'. File names must be relative and must not start with '/', '\\', or a drive root.", nameof(fileName));
|
||||
}
|
||||
|
||||
foreach (string segment in normalized.Split('/'))
|
||||
{
|
||||
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Invalid file name: '{fileName}'. File names must not contain '.' or '..' segments.", nameof(fileName));
|
||||
}
|
||||
}
|
||||
|
||||
return CombinePaths(workingFolder, fileName);
|
||||
}
|
||||
|
||||
private static string CombinePaths(string basePath, string relativePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(basePath))
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(relativePath))
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
return basePath.TrimEnd('/') + "/" + relativePath.TrimStart('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of the <see cref="FileMemoryProvider"/>,
|
||||
/// stored in the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileMemoryState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the working folder path for this session, relative to the store root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("workingFolder")]
|
||||
public string WorkingFolder { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a match found within a file during a search operation.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileSearchMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the 1-based line number where the match was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content of the matching line.
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public string Line { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a result from searching files, containing the file name, a content snippet, and matching lines.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileSearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the file that matched the search.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileName")]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a snippet of content from the file around the first match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("snippet")]
|
||||
public string Snippet { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lines where matches were found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("matchingLines")]
|
||||
public List<FileSearchMatch> MatchingLines { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An in-memory implementation of <see cref="AgentFileStore"/> that stores files in a dictionary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation is suitable for testing and lightweight scenarios where persistence is not required.
|
||||
/// Directory concepts are simulated using path prefixes — no explicit directory structure is maintained.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class InMemoryAgentFileStore : AgentFileStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _files = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = NormalizePath(path);
|
||||
this._files[path] = content;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = NormalizePath(path);
|
||||
this._files.TryGetValue(path, out string? content);
|
||||
return Task.FromResult(content);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = NormalizePath(path);
|
||||
return Task.FromResult(this._files.TryRemove(path, out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string prefix = NormalizePath(directory);
|
||||
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
prefix += "/";
|
||||
}
|
||||
|
||||
var files = this._files.Keys
|
||||
.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k.Substring(prefix.Length))
|
||||
.Where(k => k.IndexOf("/", StringComparison.Ordinal) < 0)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(files);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = NormalizePath(path);
|
||||
return Task.FromResult(this._files.ContainsKey(path));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Normalize the directory prefix for path matching.
|
||||
string prefix = NormalizePath(directory);
|
||||
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
prefix += "/";
|
||||
}
|
||||
|
||||
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
|
||||
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
|
||||
Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null;
|
||||
var results = new List<FileSearchResult>();
|
||||
|
||||
foreach (var kvp in this._files)
|
||||
{
|
||||
// Only consider files within the target directory (by path prefix).
|
||||
if (!kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude files in subdirectories (direct children only).
|
||||
string relativeName = kvp.Key.Substring(prefix.Length);
|
||||
if (relativeName.IndexOf("/", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the optional glob filter on the file name.
|
||||
if (!MatchesGlob(relativeName, matcher))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search each line for regex matches, tracking line numbers and building a snippet.
|
||||
string fileContent = kvp.Value;
|
||||
string[] lines = fileContent.Split('\n');
|
||||
var matchingLines = new List<FileSearchMatch>();
|
||||
string? firstSnippet = null;
|
||||
int lineStartOffset = 0;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
Match match = regex.Match(lines[i]);
|
||||
if (match.Success)
|
||||
{
|
||||
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
|
||||
|
||||
// Build a context snippet around the first match (±50 chars).
|
||||
if (firstSnippet is null)
|
||||
{
|
||||
int charIndex = lineStartOffset + match.Index;
|
||||
int snippetStart = Math.Max(0, charIndex - 50);
|
||||
int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
|
||||
firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the offset past this line (including the '\n' separator).
|
||||
lineStartOffset += lines[i].Length + 1;
|
||||
}
|
||||
|
||||
if (matchingLines.Count > 0)
|
||||
{
|
||||
results.Add(new FileSearchResult
|
||||
{
|
||||
FileName = relativeName,
|
||||
Snippet = firstSnippet!,
|
||||
MatchingLines = matchingLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<FileSearchResult>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// No-op: directories are implicit from file paths in the in-memory store.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
string normalized = path.Replace('\\', '/').Trim('/');
|
||||
|
||||
if (Path.IsPathRooted(path) ||
|
||||
path.StartsWith("/", StringComparison.Ordinal) ||
|
||||
path.StartsWith("\\", StringComparison.Ordinal) ||
|
||||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
|
||||
nameof(path));
|
||||
}
|
||||
|
||||
foreach (string segment in normalized.Split('/'))
|
||||
{
|
||||
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
|
||||
nameof(path));
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class FileMemoryProviderTests
|
||||
{
|
||||
#region Constructor Validation
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullFileStore_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new FileMemoryProvider(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDefaults_Succeeds()
|
||||
{
|
||||
// Act
|
||||
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithStateInitializer_Succeeds()
|
||||
{
|
||||
// Act
|
||||
var provider = new FileMemoryProvider(
|
||||
new InMemoryAgentFileStore(),
|
||||
_ => new FileMemoryState { WorkingFolder = "custom" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProvideAIContextAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
|
||||
// Assert - 5 tools: SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles
|
||||
Assert.Equal(5, tools.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("file-based memory", result.Instructions);
|
||||
Assert.Contains("compacted", result.Instructions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_CreatesFileAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Test content",
|
||||
["description"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
Assert.Equal("Test content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithDescription_CreatesBothFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "research.md",
|
||||
["content"] = "Long research content...",
|
||||
["description"] = "Summary of research findings",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var content = await store.ReadFileAsync("research.md");
|
||||
Assert.Equal("Long research content...", content);
|
||||
var desc = await store.ReadFileAsync("research_description.md");
|
||||
Assert.Equal("Summary of research findings", desc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithoutDescription_DeletesStaleDescriptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Save with description first.
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Original",
|
||||
["description"] = "Old description",
|
||||
}, session);
|
||||
Assert.NotNull(await store.ReadFileAsync("notes_description.md"));
|
||||
|
||||
// Act — overwrite without description.
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Updated",
|
||||
}, session);
|
||||
|
||||
// Assert — stale description file is removed.
|
||||
Assert.Equal("Updated", await store.ReadFileAsync("notes.md"));
|
||||
Assert.Null(await store.ReadFileAsync("notes_description.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithCustomState_CreatesInSubfolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, state, session) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "session123" });
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Session content",
|
||||
["description"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("session123", state.WorkingFolder);
|
||||
var content = await store.ReadFileAsync("session123/notes.md");
|
||||
Assert.Equal("Session content", content);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReadFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_ExistingFile_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Stored content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Equal("Stored content", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "nonexistent.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Contains("not found", text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Contains("deleted", text);
|
||||
Assert.False(await store.FileExistsAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_AlsoDeletesDescriptionFileAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Description");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.False(await store.FileExistsAsync("notes.md"));
|
||||
Assert.False(await store.FileExistsAsync("notes_description.md"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ListFiles Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_ReturnsFilesWithDescriptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "A description");
|
||||
await store.WriteFileAsync("other.md", "Other content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Equal(2, entries.Count);
|
||||
|
||||
var notesEntry = entries.First(e => e.GetProperty("fileName").GetString() == "notes.md");
|
||||
Assert.Equal("A description", notesEntry.GetProperty("description").GetString());
|
||||
|
||||
var otherEntry = entries.First(e => e.GetProperty("fileName").GetString() == "other.md");
|
||||
Assert.False(otherEntry.TryGetProperty("description", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_HidesDescriptionFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Desc");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SearchFiles Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_FindsMatchingContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Important research findings about AI");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
{
|
||||
["regexPattern"] = "research findings",
|
||||
["filePattern"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
Assert.True(entries[0].TryGetProperty("matchingLines", out var matchingLines));
|
||||
Assert.True(matchingLines.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithFilePattern_FiltersResultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Important data");
|
||||
await store.WriteFileAsync("data.txt", "Important data");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
{
|
||||
["regexPattern"] = "Important",
|
||||
["filePattern"] = "*.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Initializer Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CustomStateInitializer_SetsWorkingFolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (_, state, _) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "user42" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user42", state.WorkingFolder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultStateInitializer_UsesEmptyWorkingFolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (_, state, _) = await CreateToolsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, state.WorkingFolder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_PersistsAcrossInvocationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var provider = new FileMemoryProvider(store, _ => new FileMemoryState { WorkingFolder = "persistent" });
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
// Act - first invocation initializes state
|
||||
await provider.InvokingAsync(context);
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state1, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
// Second invocation should reuse the same folder
|
||||
await provider.InvokingAsync(context);
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state2, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state1);
|
||||
Assert.NotNull(state2);
|
||||
Assert.Equal(state1!.WorkingFolder, state2!.WorkingFolder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Traversal Protection
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "../escape.md",
|
||||
["content"] = "Content",
|
||||
["description"] = "",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_AbsolutePath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "/etc/passwd",
|
||||
["content"] = "Content",
|
||||
["description"] = "",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_DriveRootedPath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "C:\\temp\\file.md",
|
||||
["content"] = "Content",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_DoubleDotsInFileName_AllowedAsync()
|
||||
{
|
||||
// Arrange — "notes..md" is not a path traversal attempt.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes..md",
|
||||
["content"] = "Content",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", await store.ReadFileAsync("notes..md"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
|
||||
{
|
||||
return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer);
|
||||
}
|
||||
|
||||
private static async Task<(IEnumerable<AITool> Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
|
||||
{
|
||||
var provider = CreateProvider(store, stateInitializer);
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
return (result.Tools!, state!, session);
|
||||
}
|
||||
|
||||
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
|
||||
{
|
||||
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a tool within a mock <see cref="AIAgent.CurrentRunContext"/> so that
|
||||
/// the tool methods can access the session via <c>AIAgent.CurrentRunContext?.Session</c>.
|
||||
/// </summary>
|
||||
/// <param name="tool">The tool to invoke.</param>
|
||||
/// <param name="arguments">The arguments to pass to the tool.</param>
|
||||
/// <param name="session">
|
||||
/// An optional session to use in the run context. When provided, ensures the tool executes
|
||||
/// against the same session whose state was initialized during <see cref="CreateToolsAsync"/>.
|
||||
/// When <see langword="null"/>, a new session is created.
|
||||
/// </param>
|
||||
private static async Task<object?> InvokeWithRunContextAsync(AIFunction tool, AIFunctionArguments arguments, AgentSession? session = null)
|
||||
{
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
session ??= new ChatClientAgentSession();
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Set up the ambient run context so tool methods can access the session.
|
||||
var runContext = new AgentRunContext(agent, session, messages, null);
|
||||
|
||||
// Use reflection to set the protected static CurrentRunContext property.
|
||||
var property = typeof(AIAgent).GetProperty("CurrentRunContext", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
||||
var setter = property!.GetSetMethod(true)!;
|
||||
var previousContext = AIAgent.CurrentRunContext;
|
||||
try
|
||||
{
|
||||
setter.Invoke(null, [runContext]);
|
||||
return await tool.InvokeAsync(arguments);
|
||||
}
|
||||
finally
|
||||
{
|
||||
setter.Invoke(null, [previousContext]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class InMemoryAgentFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAndReadFile_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes.md", "Hello world");
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Hello world", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_NonExistent_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var content = await store.ReadFileAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.Null(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_OverwritesExistingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Original");
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes.md", "Updated");
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await store.ReadFileAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteFileAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_ReturnsDirectChildrenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/file1.md", "Content 1");
|
||||
await store.WriteFileAsync("folder/file2.md", "Content 2");
|
||||
await store.WriteFileAsync("folder/sub/file3.md", "Content 3");
|
||||
await store.WriteFileAsync("other/file4.md", "Content 4");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("folder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("file1.md", files);
|
||||
Assert.Contains("file2.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_EmptyDirectory_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("empty");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_RootDirectory_ReturnsRootFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("root.md", "Content");
|
||||
await store.WriteFileAsync("folder/nested.md", "Content");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("");
|
||||
|
||||
// Assert
|
||||
Assert.Single(files);
|
||||
Assert.Equal("root.md", files[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_IncludesDescriptionFilesAsync()
|
||||
{
|
||||
// Arrange — the store is dumb; it returns all files including _description.md
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Content");
|
||||
await store.WriteFileAsync("folder/notes_description.md", "Desc");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("folder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("notes.md", files);
|
||||
Assert.Contains("notes_description.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(await store.FileExistsAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(await store.FileExistsAsync("nonexistent.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_FindsMatchingContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "The quick brown fox jumps over the lazy dog");
|
||||
await store.WriteFileAsync("folder/other.md", "No match here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "brown fox");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
Assert.Contains("brown fox", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_ReturnsMatchingLineNumbersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Line one\nLine two with match\nLine three\nLine four with match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal("Line two with match", results[0].MatchingLines[0].Line);
|
||||
Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
|
||||
Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_CaseInsensitiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Important Data Here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "important data");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexPatternAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Error: something went wrong\nWarning: check this\nInfo: all good");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "error|warning");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(1, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal(2, results[0].MatchingLines[1].LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexWithSpecialCharactersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/code.cs", "var x = 42;\nvar y = 100;\nconst z = 7;");
|
||||
|
||||
// Act — regex matching lines starting with "var"
|
||||
var results = await store.SearchFilesAsync("folder", @"^var\b");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_FiltersFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Important data");
|
||||
await store.WriteFileAsync("folder/data.txt", "Important data");
|
||||
await store.WriteFileAsync("folder/code.cs", "Important data");
|
||||
|
||||
// Act — only search markdown files
|
||||
var results = await store.SearchFilesAsync("folder", "Important", filePattern: "*.md");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_MultipleExtensionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "match here");
|
||||
await store.WriteFileAsync("folder/data.txt", "match here");
|
||||
await store.WriteFileAsync("folder/code.cs", "match here");
|
||||
|
||||
// Act — search both md and txt files
|
||||
var resultsMd = await store.SearchFilesAsync("folder", "match", filePattern: "*.md");
|
||||
var resultsTxt = await store.SearchFilesAsync("folder", "match", filePattern: "*.txt");
|
||||
|
||||
// Assert
|
||||
Assert.Single(resultsMd);
|
||||
Assert.Equal("notes.md", resultsMd[0].FileName);
|
||||
Assert.Single(resultsTxt);
|
||||
Assert.Equal("data.txt", resultsTxt[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_PrefixMatchAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/research_ai.md", "findings");
|
||||
await store.WriteFileAsync("folder/research_ml.md", "findings");
|
||||
await store.WriteFileAsync("folder/notes.md", "findings");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "findings", filePattern: "research*");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.StartsWith("research", r.FileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithNullGlobPattern_SearchesAllFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "match");
|
||||
await store.WriteFileAsync("folder/data.txt", "match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "match", filePattern: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_NoMatch_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Some content");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "nonexistent query");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_IgnoresSubdirectoryFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Match here");
|
||||
await store.WriteFileAsync("folder/sub/deep.md", "Match here too");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "Match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_IncludesSurroundingContextAsync()
|
||||
{
|
||||
// Arrange — place the match in the middle of a long line so ±50 chars are available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string padding = new('A', 60);
|
||||
string content = $"{padding}MATCH_HERE{padding}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH_HERE");
|
||||
|
||||
// Assert — snippet should contain the match and surrounding context (up to ±50 chars).
|
||||
Assert.Single(results);
|
||||
string snippet = results[0].Snippet;
|
||||
Assert.Contains("MATCH_HERE", snippet);
|
||||
Assert.True(snippet.Length <= 50 + "MATCH_HERE".Length + 50, "Snippet should be at most ±50 chars around the match.");
|
||||
Assert.True(snippet.Length > "MATCH_HERE".Length, "Snippet should include surrounding context.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearStartOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very beginning, so no leading context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string trailing = new('B', 80);
|
||||
string content = $"MATCH{trailing}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should start at the beginning of the file.
|
||||
Assert.Single(results);
|
||||
Assert.StartsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= "MATCH".Length + 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearEndOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very end, so no trailing context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string leading = new('C', 80);
|
||||
string content = $"{leading}MATCH";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should end at the end of the file.
|
||||
Assert.Single(results);
|
||||
Assert.EndsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= 50 + "MATCH".Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_UsesFirstMatchPositionAsync()
|
||||
{
|
||||
// Arrange — "target" appears on lines 1 and 3, but the regex only matches line 3
|
||||
// because we require the word "UNIQUE" which only appears on line 3.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
const string Content = "Line one has some text\nLine two is filler\nLine three has UNIQUE_MARKER here";
|
||||
await store.WriteFileAsync("folder/file.md", Content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "UNIQUE_MARKER");
|
||||
|
||||
// Assert — snippet should be from around line 3, not line 1.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("UNIQUE_MARKER", results[0].Snippet);
|
||||
Assert.Contains("Line three", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_CorrectForMultiLineMatchAsync()
|
||||
{
|
||||
// Arrange — match is on the second line with enough distance from line 1
|
||||
// that the ±50 char snippet window does not reach the start of the file.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string line1 = new('X', 100);
|
||||
string line2 = new string('Y', 60) + "FIND_ME" + new string('Z', 60);
|
||||
string line3 = new('W', 100);
|
||||
string content = $"{line1}\n{line2}\n{line3}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "FIND_ME");
|
||||
|
||||
// Assert — snippet should contain the match from line 2.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("FIND_ME", results[0].Snippet);
|
||||
|
||||
// The match is at offset 101 (line1=100 + '\n') + 60 = 161.
|
||||
// snippetStart = 161 - 50 = 111, which is well past line 1 (ends at offset 100).
|
||||
// So line 1 content should not appear in the snippet.
|
||||
Assert.DoesNotContain("XXXX", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathNormalization_HandlesBackslashesAndTrailingSlashesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("folder\\file.md", "Content");
|
||||
var content = await store.ReadFileAsync("folder/file.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("../escape.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ReadFileAsync("folder/../../escape.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_AbsolutePath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("/etc/passwd", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DoubleDotsInFileName_AllowedAsync()
|
||||
{
|
||||
// Arrange — "notes..md" contains ".." as a substring but not as a path segment.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes..md", "Content");
|
||||
var content = await store.ReadFileAsync("notes..md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DriveRootedPath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("C:\\temp\\file.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ListFilesAsync("../other"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user