.NET: Add FileAccessProvdider and concurrency fix for FileMemoryProvider (#5583)

* Add FileAccessProvdider and concurrency fix for FileMemoryProvider

* Address PR comments
This commit is contained in:
westey
2026-04-30 15:04:56 +01:00
committed by GitHub
Unverified
parent a9dafd53c3
commit 0295b4c4c7
20 changed files with 1228 additions and 23 deletions
+1
View File
@@ -122,6 +122,7 @@
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
// and extract information from the data, then write results back as new files.
//
// The sample includes a pre-populated `data/` folder with sales transaction data.
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// Point the file store at the data/ folder that ships with the sample.
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
var fileStore = new FileSystemAgentFileStore(dataFolder);
var instructions =
"""
You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools.
## Getting started
- Start by listing available files with FileAccess_ListFiles to see what data is available.
- Read the files to understand their structure and contents.
## Working with data
- When asked to analyze data, read the relevant files first, then perform the analysis.
- Show your analysis clearly with tables, summaries, and key insights.
- When calculations are needed, work through them step by step and show your reasoning.
## Writing output
- When asked to produce output files (e.g., reports, summaries, filtered data), use FileAccess_SaveFile to write them.
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
- Confirm what you wrote and where.
## Important
- Never modify or delete the original input data files unless explicitly asked to do so.
- If asked about data you haven't read yet, read it first before answering.
- Always explain your reasoning and thought process as you work through tasks.
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
""";
// Create a compaction strategy based on the model's context window.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
AIAgent agent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseFunctionInvocation()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.BuildAIAgent(
new ChatClientAgentOptions
{
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
ChatHistoryProvider = new InMemoryChatHistoryProvider(
new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
}),
AIContextProviders =
[
new FileAccessProvider(fileStore),
],
ChatOptions = new ChatOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
},
})
.AsBuilder()
.Build();
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
agent,
title: "Data Processing Assistant",
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
@@ -0,0 +1,65 @@
# What this sample demonstrates
This sample demonstrates how to use a `ChatClientAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results.
Key features showcased:
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
- **Streaming output** — responses are streamed token-by-token for a natural experience
- **No planning mode** — this is a simple conversational sample focused on data interaction
## Prerequisites
Before running this sample, ensure you have:
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
2. Azure CLI installed and authenticated (`az login`)
## Environment Variables
Set the following environment variables:
```bash
# Required: Your Azure AI Foundry OpenAI endpoint
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
# Optional: Model deployment name (defaults to gpt-5.4)
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4"
```
## Running the Sample
```bash
cd dotnet
dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
```
## What to Expect
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
You can ask the agent to:
1. **List available files** — "What files do you have?"
2. **Analyze the data** — "What are the total sales by region?" or "Which salesperson has the highest revenue?"
3. **Create output files** — "Create a summary report as a markdown file" or "Write a CSV with monthly totals"
4. **Search for patterns** — "Find all transactions over $1000"
5. **Type `exit`** — to end the session
E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`.
## Sample Data
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
| Column | Description |
| --- | --- |
| `date` | Transaction date (YYYY-MM-DD) |
| `product` | Product name |
| `category` | Product category (Electronics, Furniture, Stationery) |
| `quantity` | Units sold |
| `unit_price` | Price per unit |
| `region` | Sales region (North, South, West) |
| `salesperson` | Name of the salesperson |
@@ -0,0 +1,50 @@
date,product,category,quantity,unit_price,region,salesperson
2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice
2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob
2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice
2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol
2025-01-10,USB-C Hub,Electronics,8,45.99,North,David
2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob
2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol
2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice
2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David
2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob
2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol
2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice
2025-01-25,Notebook Pack,Stationery,20,12.99,South,David
2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol
2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob
2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice
2025-02-02,USB-C Hub,Electronics,6,45.99,West,David
2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol
2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob
2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice
2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David
2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol
2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob
2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice
2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David
2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol
2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob
2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice
2025-02-24,USB-C Hub,Electronics,10,45.99,South,David
2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol
2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob
2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice
2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David
2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol
2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob
2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice
2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David
2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol
2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob
2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice
2025-03-19,USB-C Hub,Electronics,7,45.99,North,David
2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol
2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob
2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice
2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David
2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol
2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob
2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice
2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David
1 date product category quantity unit_price region salesperson
2 2025-01-03 Laptop Pro 15 Electronics 2 1299.99 North Alice
3 2025-01-05 Ergonomic Chair Furniture 5 349.50 South Bob
4 2025-01-07 Wireless Mouse Electronics 12 24.99 North Alice
5 2025-01-08 Standing Desk Furniture 1 599.00 West Carol
6 2025-01-10 USB-C Hub Electronics 8 45.99 North David
7 2025-01-12 Monitor 27in Electronics 3 429.00 South Bob
8 2025-01-14 Desk Lamp Furniture 6 79.95 West Carol
9 2025-01-15 Keyboard Mech Electronics 4 149.99 North Alice
10 2025-01-17 Filing Cabinet Furniture 2 189.00 South David
11 2025-01-20 Webcam HD Electronics 10 89.99 West Bob
12 2025-01-22 Laptop Pro 15 Electronics 1 1299.99 South Carol
13 2025-01-24 Ergonomic Chair Furniture 3 349.50 North Alice
14 2025-01-25 Notebook Pack Stationery 20 12.99 South David
15 2025-01-27 Wireless Mouse Electronics 15 24.99 West Carol
16 2025-01-28 Whiteboard Stationery 4 129.00 North Bob
17 2025-01-30 Standing Desk Furniture 2 599.00 South Alice
18 2025-02-02 USB-C Hub Electronics 6 45.99 West David
19 2025-02-04 Monitor 27in Electronics 2 429.00 North Carol
20 2025-02-05 Desk Lamp Furniture 8 79.95 South Bob
21 2025-02-07 Keyboard Mech Electronics 5 149.99 West Alice
22 2025-02-09 Filing Cabinet Furniture 1 189.00 North David
23 2025-02-11 Webcam HD Electronics 7 89.99 South Carol
24 2025-02-13 Laptop Pro 15 Electronics 3 1299.99 West Bob
25 2025-02-15 Notebook Pack Stationery 30 12.99 North Alice
26 2025-02-17 Ergonomic Chair Furniture 4 349.50 South David
27 2025-02-19 Wireless Mouse Electronics 20 24.99 North Carol
28 2025-02-20 Whiteboard Stationery 2 129.00 West Bob
29 2025-02-22 Standing Desk Furniture 1 599.00 North Alice
30 2025-02-24 USB-C Hub Electronics 10 45.99 South David
31 2025-02-26 Monitor 27in Electronics 4 429.00 West Carol
32 2025-02-28 Desk Lamp Furniture 3 79.95 North Bob
33 2025-03-02 Keyboard Mech Electronics 6 149.99 South Alice
34 2025-03-04 Filing Cabinet Furniture 3 189.00 West David
35 2025-03-06 Webcam HD Electronics 9 89.99 North Carol
36 2025-03-08 Laptop Pro 15 Electronics 2 1299.99 South Bob
37 2025-03-10 Notebook Pack Stationery 25 12.99 West Alice
38 2025-03-12 Ergonomic Chair Furniture 6 349.50 North David
39 2025-03-14 Wireless Mouse Electronics 18 24.99 South Carol
40 2025-03-15 Whiteboard Stationery 5 129.00 North Bob
41 2025-03-17 Standing Desk Furniture 3 599.00 West Alice
42 2025-03-19 USB-C Hub Electronics 7 45.99 North David
43 2025-03-21 Monitor 27in Electronics 5 429.00 South Carol
44 2025-03-23 Desk Lamp Furniture 4 79.95 West Bob
45 2025-03-25 Keyboard Mech Electronics 3 149.99 North Alice
46 2025-03-27 Filing Cabinet Furniture 2 189.00 South David
47 2025-03-28 Webcam HD Electronics 11 89.99 West Carol
48 2025-03-29 Laptop Pro 15 Electronics 1 1299.99 North Bob
49 2025-03-30 Notebook Pack Stationery 15 12.99 South Alice
50 2025-03-31 Ergonomic Chair Furniture 2 349.50 West David
@@ -8,3 +8,4 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| --- | --- |
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that provides file access tools to an agent
/// for saving, reading, deleting, listing, and searching files.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="FileAccessProvider"/> gives agents the ability to work with files
/// in a folder that the user has granted access to. Unlike <see cref="FileMemoryProvider"/>,
/// which provides session-scoped memory that may be isolated per session, <see cref="FileAccessProvider"/>
/// operates on a shared, persistent folder whose contents are visible across sessions and agents.
/// This makes it suitable for reading input data, writing output artifacts, and working with
/// files that have a lifetime beyond any single agent session.
/// </para>
/// <para>
/// File access is mediated through a <see cref="AgentFileStore"/> abstraction, allowing pluggable
/// backends (in-memory, local file system, remote blob storage, etc.).
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>SaveFile</c> — Save a file with the given name and content.</description></item>
/// <item><description><c>ReadFile</c> — Read the content of a file by name.</description></item>
/// <item><description><c>DeleteFile</c> — Delete a file by name.</description></item>
/// <item><description><c>ListFiles</c> — List all file names.</description></item>
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAccessProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## File Access
You have access to a shared file storage area via the `FileAccess_*` tools for reading, writing, and managing files.
These files persist beyond the current session and may be shared across sessions or agents.
Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with.
- Never delete or overwrite existing files unless the user has explicitly asked you to do so.
""";
private readonly AgentFileStore _fileStore;
private readonly string _instructions;
private AITool[]? _tools;
/// <summary>
/// Initializes a new instance of the <see cref="FileAccessProvider"/> class.
/// </summary>
/// <param name="fileStore">
/// The file store implementation used for storage operations.
/// The store should already be scoped to the desired folder or storage location.
/// </param>
/// <param name="options">Optional settings that control provider behavior. When <see langword="null"/>, defaults are used.</param>
/// <exception cref="System.ArgumentNullException">Thrown when <paramref name="fileStore"/> is <see langword="null"/>.</exception>
public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? options = null)
{
Throw.IfNull(fileStore);
this._fileStore = fileStore;
this._instructions = options?.Instructions ?? DefaultInstructions;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => [];
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._instructions,
Tools = this._tools ??= this.CreateTools(),
});
}
/// <summary>
/// Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.
/// </summary>
/// <param name="fileName">The name of the file to save.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="overwrite">Whether to overwrite the file if it already exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message.</returns>
[Description("Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.")]
private async Task<string> SaveFileAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false))
{
return $"File '{fileName}' already exists. To replace it, save again with overwrite set to true.";
}
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
return $"File '{fileName}' saved.";
}
/// <summary>
/// Read the content of a file by name. Returns the file content or a message indicating the file was not found.
/// </summary>
/// <param name="fileName">The name of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")]
private async Task<string> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}
/// <summary>
/// Delete a file by name.
/// </summary>
/// <param name="fileName">The name of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation or not-found message.</returns>
[Description("Delete a file by name.")]
private async Task<string> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
}
/// <summary>
/// List all file names.
/// </summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file names.</returns>
[Description("List all file names.")]
private async Task<List<string>> ListFilesAsync(CancellationToken cancellationToken = default)
{
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(string.Empty, cancellationToken).ConfigureAwait(false);
return new List<string>(fileNames);
}
/// <summary>
/// Search file contents using a regular expression pattern (case-insensitive).
/// Optionally filter which files to search using a glob pattern.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="filePattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
[Description("Search file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, snippets, and matching lines with line numbers.")]
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
{
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
return new List<FileSearchResult>(results);
}
private AITool[] CreateTools()
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SaveFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ReadFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_DeleteFile", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ListFiles", SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SearchFiles", SerializerOptions = serializerOptions }),
];
}
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="FileAccessProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAccessProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the file access tools.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use file storage effectively.
/// </value>
public string? Instructions { get; set; }
}
@@ -40,7 +40,7 @@ namespace Microsoft.Agents.AI;
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileMemoryProvider : AIContextProvider
public sealed class FileMemoryProvider : AIContextProvider, IDisposable
{
private const string DescriptionSuffix = "_description.md";
private const string MemoryIndexFileName = "memories.md";
@@ -49,7 +49,8 @@ public sealed class FileMemoryProvider : AIContextProvider
private const string DefaultInstructions =
"""
## File Based Memory
You have access to a file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions.
You have access to a session-scoped, file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions.
These files act as your working memory for the current session and are isolated from other sessions.
Use these tools to store plans, memories, processing results, or downloaded data.
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
@@ -63,6 +64,7 @@ public sealed class FileMemoryProvider : AIContextProvider
private readonly AgentFileStore _fileStore;
private readonly ProviderSessionState<FileMemoryState> _sessionState;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
private AITool[]? _tools;
@@ -93,6 +95,14 @@ public sealed class FileMemoryProvider : AIContextProvider
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Releases the resources used by the <see cref="FileMemoryProvider"/>.
/// </summary>
public void Dispose()
{
this._writeLock.Dispose();
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
@@ -147,26 +157,35 @@ public sealed class FileMemoryProvider : AIContextProvider
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._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false);
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);
}
string result = string.IsNullOrWhiteSpace(description)
? $"File '{fileName}' saved."
: $"File '{fileName}' saved with description.";
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return result;
}
else
finally
{
// Remove any stale description file when no description is provided.
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
this._writeLock.Release();
}
string result = string.IsNullOrWhiteSpace(description)
? $"File '{fileName}' saved."
: $"File '{fileName}' saved with description.";
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return result;
}
/// <summary>
@@ -196,14 +215,23 @@ public sealed class FileMemoryProvider : AIContextProvider
{
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);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
// Also delete companion description file if it exists.
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
@@ -0,0 +1,604 @@
// 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.FileAccess;
public class FileAccessProviderTests
{
#region Constructor Validation
[Fact]
public void Constructor_NullFileStore_Throws()
{
Assert.Throws<ArgumentNullException>(() => new FileAccessProvider(null!));
}
[Fact]
public void Constructor_WithDefaults_Succeeds()
{
// Act
var provider = new FileAccessProvider(new InMemoryAgentFileStore());
// Assert
Assert.NotNull(provider);
}
#endregion
#region ProvideAIContextAsync Tests
[Fact]
public async Task ProvideAIContextAsync_ReturnsToolsAsync()
{
// Arrange
var tools = 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 FileAccessProvider(new InMemoryAgentFileStore());
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("File Access", result.Instructions);
Assert.Contains("FileAccess_", result.Instructions);
Assert.Contains("persist beyond the current session", result.Instructions);
}
[Fact]
public async Task ProvideAIContextAsync_DoesNotInjectMessagesAsync()
{
// Arrange — FileAccessProvider should never inject messages (unlike FileMemoryProvider).
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Content");
var provider = new FileAccessProvider(store);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
[Fact]
public void StateKeys_ReturnsEmpty()
{
// Arrange — FileAccessProvider has no session state.
var provider = new FileAccessProvider(new InMemoryAgentFileStore());
// Act
var keys = provider.StateKeys;
// Assert
Assert.Empty(keys);
}
#endregion
#region SaveFile Tests
[Fact]
public async Task SaveFile_CreatesFileAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Test content",
});
// Assert
var content = await store.ReadFileAsync("notes.md");
Assert.Equal("Test content", content);
}
[Fact]
public async Task SaveFile_DoesNotCreateDescriptionSidecarAsync()
{
// Arrange — FileAccessProvider should never create description sidecar files.
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "research.md",
["content"] = "Long research content...",
});
// Assert — file exists, no description sidecar
Assert.Equal("Long research content...", await store.ReadFileAsync("research.md"));
Assert.Null(await store.ReadFileAsync("research_description.md"));
}
[Fact]
public async Task SaveFile_ExistingFile_WithoutOverwrite_ReturnsErrorAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Original",
});
// Act — try to save again without overwrite
var result = await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Updated",
});
// Assert — original content preserved, error message returned
Assert.Equal("Original", await store.ReadFileAsync("notes.md"));
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Contains("already exists", text);
}
[Fact]
public async Task SaveFile_ExistingFile_WithOverwrite_SucceedsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Original",
});
// Act — save again with overwrite=true
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Updated",
["overwrite"] = true,
});
// Assert
Assert.Equal("Updated", await store.ReadFileAsync("notes.md"));
}
[Fact]
public async Task SaveFile_ReturnsConfirmationAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act
var result = await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "test.md",
["content"] = "Content",
});
// Assert
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Contains("saved", text);
}
#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 = await CreateToolsAsync(store);
var readFile = GetTool(tools, "FileAccess_ReadFile");
// Act
var result = await InvokeToolAsync(readFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
});
// Assert
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Equal("Stored content", text);
}
[Fact]
public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var readFile = GetTool(tools, "FileAccess_ReadFile");
// Act
var result = await InvokeToolAsync(readFile, new AIFunctionArguments
{
["fileName"] = "nonexistent.md",
});
// Assert
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Contains("not found", text);
}
#endregion
#region DeleteFile Tests
[Fact]
public async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Content");
var tools = await CreateToolsAsync(store);
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
// Act
var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
});
// Assert
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Contains("deleted", text);
Assert.False(await store.FileExistsAsync("notes.md"));
}
[Fact]
public async Task DeleteFile_NonExistent_ReturnsNotFoundAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
// Act
var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments
{
["fileName"] = "missing.md",
});
// Assert
var text = Assert.IsType<JsonElement>(result).GetString();
Assert.Contains("not found", text);
}
#endregion
#region ListFiles Tests
[Fact]
public async Task ListFiles_ReturnsFileNamesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Content");
await store.WriteFileAsync("data.txt", "Data");
var tools = await CreateToolsAsync(store);
var listFiles = GetTool(tools, "FileAccess_ListFiles");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
// Assert — returns plain list of file names (no description properties)
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, e => e.GetString() == "data.txt");
Assert.Contains(entries, e => e.GetString() == "notes.md");
}
[Fact]
public async Task ListFiles_DoesNotFilterDescriptionFilesAsync()
{
// Arrange — FileAccessProvider doesn't know about description sidecars, so all files are visible.
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Content");
await store.WriteFileAsync("notes_description.md", "Description");
var tools = await CreateToolsAsync(store);
var listFiles = GetTool(tools, "FileAccess_ListFiles");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
// Assert — both files should be visible
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Equal(2, entries.Count);
}
[Fact]
public async Task ListFiles_EmptyStore_ReturnsEmptyListAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var listFiles = GetTool(tools, "FileAccess_ListFiles");
// Act
var result = await InvokeToolAsync(listFiles, new AIFunctionArguments());
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Empty(entries);
}
#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 = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "research findings",
["filePattern"] = "",
});
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Single(entries);
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
Assert.True(entries[0].TryGetProperty("matchingLines", out var matchingLines));
Assert.True(matchingLines.GetArrayLength() > 0);
}
[Fact]
public async Task SearchFiles_WithFilePattern_FiltersResultsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "Important data");
await store.WriteFileAsync("data.txt", "Important data");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "Important",
["filePattern"] = "*.md",
});
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Single(entries);
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
}
[Fact]
public async Task SearchFiles_NoMatches_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteFileAsync("notes.md", "No matching content here");
var tools = await CreateToolsAsync(store);
var searchFiles = GetTool(tools, "FileAccess_SearchFiles");
// Act
var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments
{
["regexPattern"] = "nonexistent pattern xyz",
});
// Assert
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Empty(entries);
}
#endregion
#region Path Traversal Protection
[Fact]
public async Task SaveFile_PathTraversal_ThrowsAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "../escape.md",
["content"] = "Content",
}));
}
[Fact]
public async Task SaveFile_AbsolutePath_ThrowsAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "/etc/passwd",
["content"] = "Content",
}));
}
[Fact]
public async Task SaveFile_DriveRootedPath_ThrowsAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "C:\\temp\\file.md",
["content"] = "Content",
}));
}
[Fact]
public async Task SaveFile_DoubleDotsInFileName_AllowedAsync()
{
// Arrange — "notes..md" is not a path traversal attempt.
var store = new InMemoryAgentFileStore();
var tools = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileAccess_SaveFile");
// Act
await InvokeToolAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes..md",
["content"] = "Content",
});
// Assert
Assert.Equal("Content", await store.ReadFileAsync("notes..md"));
}
[Fact]
public async Task ReadFile_PathTraversal_ThrowsAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var readFile = GetTool(tools, "FileAccess_ReadFile");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeToolAsync(readFile, new AIFunctionArguments
{
["fileName"] = "../../etc/passwd",
}));
}
[Fact]
public async Task DeleteFile_PathTraversal_ThrowsAsync()
{
// Arrange
var tools = await CreateToolsAsync();
var deleteFile = GetTool(tools, "FileAccess_DeleteFile");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeToolAsync(deleteFile, new AIFunctionArguments
{
["fileName"] = "../escape.md",
}));
}
#endregion
#region Options Tests
[Fact]
public async Task Options_CustomInstructions_OverridesDefaultAsync()
{
// Arrange
var options = new FileAccessProviderOptions { Instructions = "Custom file access instructions." };
var provider = new FileAccessProvider(new InMemoryAgentFileStore(), options: options);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Equal("Custom file access instructions.", result.Instructions);
}
[Fact]
public async Task Options_Null_UsesDefaultInstructionsAsync()
{
// Arrange
var provider = new FileAccessProvider(new InMemoryAgentFileStore());
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Contains("File Access", result.Instructions);
}
#endregion
#region Helper Methods
private static async Task<IEnumerable<AITool>> CreateToolsAsync(InMemoryAgentFileStore? store = null)
{
var provider = new FileAccessProvider(store ?? new InMemoryAgentFileStore());
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
return result.Tools!;
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
/// <summary>
/// Invokes a tool. Since <see cref="FileAccessProvider"/> does not use session state,
/// the tools don't need an ambient <see cref="AIAgent.CurrentRunContext"/>.
/// </summary>
private static async Task<object?> InvokeToolAsync(AIFunction tool, AIFunctionArguments arguments)
{
return await tool.InvokeAsync(arguments);
}
#endregion
}
@@ -793,4 +793,124 @@ public class FileMemoryProviderTests
}
#endregion
#region Thread Safety Tests
[Fact]
public async Task ConcurrentSaves_ProduceConsistentIndexAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var (tools, _, session) = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileMemory_SaveFile");
const int FileCount = 20;
// Act — save multiple files in parallel.
var tasks = new Task[FileCount];
for (int i = 0; i < FileCount; i++)
{
int index = i;
tasks[i] = InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = $"file{index}.md",
["content"] = $"Content {index}",
["description"] = $"Description {index}",
}, session);
}
await Task.WhenAll(tasks);
// Assert — the memory index should contain all files.
string? indexContent = await store.ReadFileAsync("memories.md");
Assert.NotNull(indexContent);
for (int i = 0; i < FileCount; i++)
{
Assert.Contains($"**file{i}.md**", indexContent);
}
}
[Fact]
public async Task ConcurrentSaveAndDelete_ProduceConsistentIndexAsync()
{
// Arrange — pre-populate files that will be deleted.
var store = new InMemoryAgentFileStore();
var (tools, _, session) = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileMemory_SaveFile");
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
for (int i = 0; i < 5; i++)
{
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = $"delete{i}.md",
["content"] = $"To be deleted {i}",
}, session);
}
// Act — concurrently save new files and delete existing ones.
var tasks = new List<Task>();
for (int i = 0; i < 5; i++)
{
int index = i;
tasks.Add(InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = $"keep{index}.md",
["content"] = $"Kept {index}",
}, session));
tasks.Add(InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
{
["fileName"] = $"delete{index}.md",
}, session));
}
await Task.WhenAll(tasks);
// Assert — index should contain only the kept files.
string? indexContent = await store.ReadFileAsync("memories.md");
Assert.NotNull(indexContent);
for (int i = 0; i < 5; i++)
{
Assert.Contains($"**keep{i}.md**", indexContent);
Assert.DoesNotContain($"**delete{i}.md**", indexContent);
}
}
[Fact]
public void Dispose_ReleasesResources()
{
// Arrange
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
// Act
provider.Dispose();
// Assert — calling Dispose again should not throw (idempotent SemaphoreSlim.Dispose).
provider.Dispose();
}
[Fact]
public async Task SaveFile_AfterDispose_ThrowsAsync()
{
// Arrange — create tools from a provider, then dispose the provider.
var store = new InMemoryAgentFileStore();
var provider = CreateProvider(store);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
var saveFile = GetTool(result.Tools!, "FileMemory_SaveFile");
provider.Dispose();
// Act & Assert — the disposed SemaphoreSlim should throw ObjectDisposedException.
await Assert.ThrowsAsync<ObjectDisposedException>(async () =>
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Should fail",
}, session));
}
#endregion
}