diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 4e32c2198f..4881564407 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -75,6 +75,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs index 9eb9ca5090..e3430ce10d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs @@ -41,6 +41,13 @@ public static string Format(FunctionCallContent call) "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 string Format(FunctionCallContent call) : $"(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); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index fdd4d8abd1..3494b986c9 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -90,7 +90,7 @@ This rule applies even when the answer seems obvious or the task seems small. { 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(), diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 04f0bcdd03..cbcffcc679 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -81,6 +81,15 @@ private static JsonSerializerOptions CreateDefaultOptions() // AgentModeProvider types [JsonSerializable(typeof(AgentModeState))] + // FileMemoryProvider types + [JsonSerializable(typeof(FileMemoryState))] + [JsonSerializable(typeof(FileSearchResult))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileSearchResultList")] + [JsonSerializable(typeof(FileSearchMatch))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileSearchMatchList")] + [JsonSerializable(typeof(FileListEntry))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileListEntryList")] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs new file mode 100644 index 0000000000..ad8bbe4390 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs @@ -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; + +/// +/// Provides an abstract base class for file storage operations. +/// +/// +/// +/// 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. +/// +/// +/// Paths use forward slashes as separators and must not escape the root (e.g., via .. segments). +/// It is up to each implementation to ensure that this is enforced. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentFileStore +{ + /// + /// Writes content to a file, creating or overwriting it. + /// + /// The relative path of the file to write. + /// The content to write to the file. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public abstract Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default); + + /// + /// Reads the content of a file. + /// + /// The relative path of the file to read. + /// A token to cancel the operation. + /// The file content, or if the file does not exist. + public abstract Task ReadFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Deletes a file. + /// + /// The relative path of the file to delete. + /// A token to cancel the operation. + /// if the file was deleted; if it did not exist. + public abstract Task DeleteFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Lists files in a directory. + /// + /// The relative path of the directory to list. Use an empty string for the root. + /// A token to cancel the operation. + /// A list of file names in the specified directory (direct children only). + public abstract Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default); + + /// + /// Checks whether a file exists. + /// + /// The relative path of the file to check. + /// A token to cancel the operation. + /// if the file exists; otherwise, . + public abstract Task FileExistsAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Searches for files whose content matches a regular expression pattern. + /// + /// The relative path of the directory to search. Use an empty string for the root. + /// + /// A regular expression pattern to match against file contents. The pattern is matched case-insensitively. + /// For example, "error|warning" matches lines containing "error" or "warning". + /// + /// + /// An optional glob pattern to filter which files are searched (e.g., "*.md", "research*"). + /// When , all files in the directory are searched. + /// Uses standard glob syntax from . + /// + /// A token to cancel the operation. + /// A list of search results with matching file names, snippets, and matching lines. + public abstract Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default); + + /// + /// Ensures a directory exists, creating it if necessary. + /// + /// The relative path of the directory to create. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Creates a for the specified glob pattern. Use the returned instance + /// to test multiple file names without allocating a new matcher for each one. + /// + /// + /// The glob pattern to match against (e.g., "*.md", "research*"). + /// + /// A configured with the specified pattern. + protected static Matcher CreateGlobMatcher(string filePattern) + { + var matcher = new Matcher(System.StringComparison.OrdinalIgnoreCase); + matcher.AddInclude(filePattern); + return matcher; + } + + /// + /// Determines whether a file name matches a pre-built glob . + /// + /// The file name to test (not a full path — just the name). + /// + /// A pre-built to test against. + /// When , this method returns for any file name. + /// + /// if the file name matches the pattern or if the matcher is ; otherwise, . + protected static bool MatchesGlob(string fileName, Matcher? matcher) + { + if (matcher is null) + { + return true; + } + + PatternMatchingResult result = matcher.Match(fileName); + return result.HasMatches; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs new file mode 100644 index 0000000000..430b437516 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs @@ -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; + +/// +/// Represents a file entry returned by the list files tool, +/// containing the file name and an optional description. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileListEntry +{ + /// + /// Gets or sets the name of the file. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets the description of the file, or if no description is available. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs new file mode 100644 index 0000000000..664ea7ed11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -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; + +/// +/// An that provides file-based memory tools to an agent +/// for storing, retrieving, modifying, listing, deleting, and searching files. +/// +/// +/// +/// The 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 _description.md) can be stored +/// alongside the main file to provide a summary. +/// +/// +/// File access is mediated through a abstraction, allowing pluggable +/// backends (in-memory, local file system, remote blob storage, etc.). +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// SaveFile — Save a memory file with the given name, content, and an optional description. +/// ReadFile — Read the content of a file by name. +/// DeleteFile — Delete a file by name. +/// ListFiles — List all files with their descriptions (if available). +/// SearchFiles — Search file contents using a regular expression pattern. +/// +/// +/// +[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 _sessionState; + private IReadOnlyList? _stateKeys; + private AITool[]? _tools; + + /// + /// Initializes a new instance of the class. + /// + /// The file store implementation used for storage operations. + /// + /// An optional function that initializes the for a new session. + /// Use this to customize the working folder (e.g., per-user or per-session subfolders). + /// When , the default initializer creates state with an empty working folder. + /// + /// Thrown when is . + public FileMemoryProvider(AgentFileStore fileStore, Func? stateInitializer = null) + { + Throw.IfNull(fileStore); + + this._fileStore = fileStore; + this._sessionState = new ProviderSessionState( + stateInitializer ?? (_ => new FileMemoryState()), + this.GetType().Name, + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + protected override async ValueTask 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(), + }; + } + + /// + /// 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. + /// + /// The name of the file to save. + /// The content to write to the file. + /// An optional description of the file contents for discovery. Leave empty or omit to skip. + /// A token to cancel the operation. + /// A confirmation message. + [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 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."; + } + + /// + /// Read the content of a memory file by name. + /// Returns the file content or a message indicating the file was not found. + /// + /// The name of the file to read. + /// A token to cancel the operation. + /// The file content or a not-found message. + [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 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."; + } + + /// + /// Delete a memory file by name. Also removes its companion description file if one exists. + /// + /// The name of the file to delete. + /// A token to cancel the operation. + /// A confirmation or not-found message. + [Description("Delete a memory file by name. Also removes its companion description file if one exists.")] + private async Task 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."; + } + + /// + /// List all memory files with their descriptions (if available). Description files are not shown separately. + /// + /// A token to cancel the operation. + /// A list of file entries with names and optional descriptions. + [Description("List all memory files with their descriptions (if available). Description files are not shown separately.")] + private async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false); + + var descriptionFileSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string file in fileNames) + { + if (file.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase)) + { + descriptionFileSet.Add(file); + } + } + + var entries = new List(); + 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; + } + + /// + /// 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. + /// + /// A regular expression pattern to match against file contents (case-insensitive). + /// An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files. + /// A token to cancel the operation. + /// A list of search results with matching file names, snippets, and matching lines. + [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> 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 results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false); + return new List(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('/'); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs new file mode 100644 index 0000000000..fc32da0c7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs @@ -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; + +/// +/// Represents the state of the , +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileMemoryState +{ + /// + /// Gets or sets the working folder path for this session, relative to the store root. + /// + [JsonPropertyName("workingFolder")] + public string WorkingFolder { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs new file mode 100644 index 0000000000..0bf2d102d3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs @@ -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; + +/// +/// Represents a match found within a file during a search operation. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileSearchMatch +{ + /// + /// Gets or sets the 1-based line number where the match was found. + /// + [JsonPropertyName("lineNumber")] + public int LineNumber { get; set; } + + /// + /// Gets or sets the content of the matching line. + /// + [JsonPropertyName("line")] + public string Line { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs new file mode 100644 index 0000000000..162bb36e73 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs @@ -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; + +/// +/// Represents a result from searching files, containing the file name, a content snippet, and matching lines. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileSearchResult +{ + /// + /// Gets or sets the name of the file that matched the search. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets a snippet of content from the file around the first match. + /// + [JsonPropertyName("snippet")] + public string Snippet { get; set; } = string.Empty; + + /// + /// Gets or sets the lines where matches were found. + /// + [JsonPropertyName("matchingLines")] + public List MatchingLines { get; set; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs new file mode 100644 index 0000000000..670ef992ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs @@ -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; + +/// +/// An in-memory implementation of that stores files in a dictionary. +/// +/// +/// 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. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class InMemoryAgentFileStore : AgentFileStore +{ + private readonly ConcurrentDictionary _files = new(StringComparer.OrdinalIgnoreCase); + + /// + public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + this._files[path] = content; + return Task.CompletedTask; + } + + /// + public override Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + this._files.TryGetValue(path, out string? content); + return Task.FromResult(content); + } + + /// + public override Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + return Task.FromResult(this._files.TryRemove(path, out _)); + } + + /// + public override Task> 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>(files); + } + + /// + public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + return Task.FromResult(this._files.ContainsKey(path)); + } + + /// + public override Task> 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(); + + 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(); + 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>(results); + } + + /// + 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 10e92850d5..98901cfbfd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -26,6 +26,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs new file mode 100644 index 0000000000..83b2bf3d1d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -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(() => 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().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(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(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(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(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(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(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(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().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("FileMemoryProvider", out var state1, AgentJsonUtilities.DefaultOptions); + + // Second invocation should reuse the same folder + await provider.InvokingAsync(context); + session.StateBag.TryGetValue("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(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(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(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? stateInitializer = null) + { + return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer); + } + + private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) + { + var provider = CreateProvider(store, stateInitializer); + var agent = new Mock().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("FileMemoryProvider", out var state, AgentJsonUtilities.DefaultOptions); + + return (result.Tools!, state!, session); + } + + private static AIFunction GetTool(IEnumerable tools, string name) + { + return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name); + } + + /// + /// Invokes a tool within a mock so that + /// the tool methods can access the session via AIAgent.CurrentRunContext?.Session. + /// + /// The tool to invoke. + /// The arguments to pass to the tool. + /// + /// An optional session to use in the run context. When provided, ensures the tool executes + /// against the same session whose state was initialized during . + /// When , a new session is created. + /// + private static async Task InvokeWithRunContextAsync(AIFunction tool, AIFunctionArguments arguments, AgentSession? session = null) + { + var agent = new Mock().Object; + session ??= new ChatClientAgentSession(); + var messages = new List(); + + // 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 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs new file mode 100644 index 0000000000..a6a513017c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs @@ -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(() => store.WriteFileAsync("../escape.md", "Content")); + } + + [Fact] + public async Task ReadFile_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.ReadFileAsync("folder/../../escape.md")); + } + + [Fact] + public async Task WriteFile_AbsolutePath_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => 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(() => store.WriteFileAsync("C:\\temp\\file.md", "Content")); + } + + [Fact] + public async Task ListFiles_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.ListFilesAsync("../other")); + } +}