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