.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:
westey
2026-04-20 14:34:31 +01:00
committed by GitHub
Unverified
parent 99627e41d2
commit 8dca006edd
14 changed files with 1853 additions and 1 deletions
+1
View File
@@ -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" />
@@ -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
}
@@ -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"));
}
}