.NET: Harness filememory index plus instructions consistency (#5540)

* Add FileMemoryProvider index and improve instruction consistency

* Address PR comments.

* Address PR comments

* Address PR comments.

* Apply suggestion from @rogerbarreto

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
westey
2026-04-29 17:20:43 +01:00
committed by GitHub
Unverified
parent 899394a58c
commit e3f76618c5
11 changed files with 396 additions and 60 deletions
@@ -31,7 +31,7 @@ internal sealed partial class WebBrowsingTool : AIFunction
CancellationToken cancellationToken) =>
this._inner.InvokeAsync(arguments, cancellationToken);
[Description("Download the html from the given url as markdown")]
[Description("Fetch the html from the given url as markdown")]
private static async Task<string> DownloadUriAsync(
[Description("The URL to download")] string uri,
CancellationToken cancellationToken = default)
@@ -41,6 +41,15 @@ internal sealed partial class WebBrowsingTool : AIFunction
return $"Error: '{uri}' is not a valid URL.";
}
if (parsedUri.Scheme is not "http" and not "https")
{
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
}
// NOTE: In production scenarios, consider also blocking requests to private/internal IP
// ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254)
// to prevent SSRF attacks via prompt injection in web content.
try
{
string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken);
@@ -41,6 +41,20 @@ namespace Microsoft.Agents.AI;
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentModeProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## Agent Mode
You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
Use the AgentMode_Get tool to check your current operating mode.
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
{available_modes}
You are currently operating in the {current_mode} mode.
""";
private static readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> s_defaultModes =
[
new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."),
@@ -50,7 +64,7 @@ public sealed class AgentModeProvider : AIContextProvider
private readonly ProviderSessionState<AgentModeState> _sessionState;
private readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> _modes;
private readonly string _defaultMode;
private readonly string? _customInstructions;
private readonly string? _instructions;
private readonly HashSet<string> _validModeNames;
private readonly string _modeNamesDisplay;
private IReadOnlyList<string>? _stateKeys;
@@ -68,7 +82,7 @@ public sealed class AgentModeProvider : AIContextProvider
throw new ArgumentException("At least one mode must be configured.", nameof(options));
}
this._customInstructions = options?.Instructions;
this._instructions = options?.Instructions ?? DefaultInstructions;
this._validModeNames = new HashSet<string>(StringComparer.Ordinal);
var modeNamesList = new List<string>(this._modes.Count);
@@ -147,7 +161,7 @@ public sealed class AgentModeProvider : AIContextProvider
{
AgentModeState state = this._sessionState.GetOrInitializeState(context.Session);
string instructions = this._customInstructions ?? this.BuildDefaultInstructions(state.CurrentMode);
string instructions = this.BuildInstructions(state.CurrentMode);
var aiContext = new AIContext
{
@@ -171,22 +185,20 @@ public sealed class AgentModeProvider : AIContextProvider
return new ValueTask<AIContext>(aiContext);
}
private string BuildDefaultInstructions(string currentMode)
private string BuildInstructions(string currentMode)
{
var sb = new StringBuilder();
sb.Append($"You are currently operating in \"{currentMode}\" mode.");
sb.AppendLine();
sb.AppendLine("Available modes:");
// Build list of modes text:
var modesListBuilder = new StringBuilder();
foreach (var mode in this._modes)
{
sb.AppendLine($"- \"{mode.Name}\": {mode.Description}");
modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}");
}
var modesListText = modesListBuilder.ToString();
sb.AppendLine("Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.");
sb.Append("Use the AgentMode_Get tool to check your current operating mode.");
return sb.ToString();
return new StringBuilder(this._instructions)
.Replace("{available_modes}", modesListText)
.Replace("{current_mode}", currentMode)
.ToString();
}
private void ValidateMode(string mode)
@@ -17,9 +17,13 @@ public sealed class AgentModeProviderOptions
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the mode tools.
/// </summary>
/// <remarks>
/// The instructions must contain a <c>{available_modes}</c> placeholder for the provider to inject the
/// currently available list of modes, and a <c>{current_mode}</c> placeholder to inject the currently
/// active mode.
/// </remarks>
/// <value>
/// When <see langword="null"/> (the default), the provider generates instructions dynamically
/// from the configured <see cref="Modes"/> list.
/// When <see langword="null"/> (the default), the provider uses a default set of instructions.
/// </value>
public string? Instructions { get; set; }
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -42,18 +43,22 @@ namespace Microsoft.Agents.AI;
public sealed class FileMemoryProvider : AIContextProvider
{
private const string DescriptionSuffix = "_description.md";
private const string MemoryIndexFileName = "memories.md";
private const int MaxIndexEntries = 50;
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.
## File Based Memory
You have access to a file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions.
Use these tools to store plans, memories, processing results, or downloaded data.
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
- Include a description when saving a file to help with future discovery.
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing 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;
@@ -99,11 +104,27 @@ public sealed class FileMemoryProvider : AIContextProvider
await this._fileStore.CreateDirectoryAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
}
return new AIContext
var aiContext = new AIContext
{
Instructions = this._instructions,
Tools = this._tools ??= this.CreateTools(),
};
// Inject the memory index as a user message so the agent knows what memories are available.
string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName);
string? indexContent = await this._fileStore.ReadFileAsync(indexPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(indexContent))
{
aiContext.Messages =
[
new ChatMessage(ChatRole.User,
"The following is your memory index — a list of files you have previously saved. " +
"You can read any of these files using the FileMemory_ReadFile tool.\n\n" +
indexContent),
];
}
return aiContext;
}
/// <summary>
@@ -119,6 +140,11 @@ public sealed class FileMemoryProvider : AIContextProvider
[Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")]
private async Task<string> SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
{
if (IsInternalFile(fileName))
{
throw new ArgumentException("The provided file name is reserved by the system for internal use. Please choose a different file name.", nameof(fileName));
}
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, fileName);
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
@@ -135,9 +161,12 @@ public sealed class FileMemoryProvider : AIContextProvider
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
}
return string.IsNullOrWhiteSpace(description)
string result = string.IsNullOrWhiteSpace(description)
? $"File '{fileName}' saved."
: $"File '{fileName}' saved with description.";
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return result;
}
/// <summary>
@@ -173,6 +202,7 @@ public sealed class FileMemoryProvider : AIContextProvider
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.";
}
@@ -204,6 +234,11 @@ public sealed class FileMemoryProvider : AIContextProvider
continue;
}
if (IsInternalFile(file))
{
continue;
}
string? fileDescription = null;
string descFileName = GetDescriptionFileName(file);
@@ -234,7 +269,20 @@ public sealed class FileMemoryProvider : AIContextProvider
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
return new List<FileSearchResult>(results);
// Filter out internal files (description sidecars and memory index) so they stay hidden.
var filtered = new List<FileSearchResult>(results.Count);
foreach (var result in results)
{
if (IsInternalFile(result.FileName))
{
continue;
}
filtered.Add(result);
}
return filtered;
}
private AITool[] CreateTools()
@@ -251,6 +299,56 @@ public sealed class FileMemoryProvider : AIContextProvider
];
}
/// <summary>
/// Rebuilds the <c>memories.md</c> index file by listing all user files in the working folder,
/// reading their companion description files, and writing a markdown summary capped at <see cref="MaxIndexEntries"/> entries.
/// </summary>
private async Task RebuildMemoryIndexAsync(FileMemoryState state, CancellationToken cancellationToken)
{
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
// Sort deterministically so the index is stable across runs and platforms.
var sortedFiles = fileNames.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine("# Memory Index");
sb.AppendLine();
int count = 0;
foreach (string file in sortedFiles)
{
// Skip internal system files.
if (IsInternalFile(file))
{
continue;
}
if (count >= MaxIndexEntries)
{
break;
}
string? description = null;
string descFileName = GetDescriptionFileName(file);
string descPath = CombinePaths(state.WorkingFolder, descFileName);
description = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(description))
{
sb.AppendLine($"- **{file}**: {description}");
}
else
{
sb.AppendLine($"- **{file}**");
}
count++;
}
string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName);
await this._fileStore.WriteFileAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
private static string GetDescriptionFileName(string fileName)
{
int extIndex = fileName.LastIndexOf('.');
@@ -264,6 +362,14 @@ public sealed class FileMemoryProvider : AIContextProvider
return fileName + DescriptionSuffix;
}
/// <summary>
/// Returns <see langword="true"/> if the file is an internal system file that should be hidden
/// from user-facing operations (description sidecars and the memory index).
/// </summary>
private static bool IsInternalFile(string fileName) =>
fileName.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase) ||
fileName.Equals(MemoryIndexFileName, StringComparison.OrdinalIgnoreCase);
private static string ResolvePath(string workingFolder, string fileName)
{
// Validate and normalize the file name (rejects rooted, traversal, empty, etc.).
@@ -30,6 +30,16 @@ internal static class StorePaths
/// </exception>
internal static string NormalizeRelativePath(string path, bool isDirectory = false)
{
if (string.IsNullOrWhiteSpace(path))
{
if (!isDirectory)
{
throw new ArgumentException("A file path must not be empty or whitespace-only.", nameof(path));
}
return string.Empty;
}
string normalized = path.Replace('\\', '/').Trim('/');
if (Path.IsPathRooted(path) ||
@@ -40,18 +40,15 @@ public sealed class SubAgentsProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## SubAgents
You have access to sub-agents that can perform work on your behalf.
Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
Creating a sub task does not block, and sub-tasks run concurrently.
Important: Always wait for outstanding tasks to finish before you finish processing.
Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
Use SubAgents_StartTask to delegate work to a sub-agent. This will send the task to the sub agent and return immediately. Sub-tasks run concurrently.
Use SubAgents_WaitForFirstCompletion to block until one of the specified tasks finishes.
Use SubAgents_GetTaskResults to retrieve the output of a completed task.
Use SubAgents_GetAllTasks to see the status of all sub-tasks.
Use SubAgents_ContinueTask to send follow-up input to a completed sub-task (e.g., provide clarification or additional instructions).
Use SubAgents_ClearCompletedTask to remove a completed task and free its memory after you no longer need its results or session.
- Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
- Creating a sub task does not block, and sub-tasks run concurrently.
- Important: Always wait for outstanding tasks to finish before you finish processing.
- Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
{sub_agents}
""";
private readonly Dictionary<string, AIAgent> _agents;
@@ -77,7 +74,7 @@ public sealed class SubAgentsProvider : AIContextProvider
string agentListText = options?.AgentListBuilder is not null
? options.AgentListBuilder(this._agents)
: BuildDefaultAgentListText(this._agents);
this._instructions = baseInstructions + "\n" + agentListText;
this._instructions = baseInstructions.Replace("{sub_agents}", agentListText);
this._sessionState = new ProviderSessionState<SubAgentState>(
_ => new SubAgentState(),
@@ -260,7 +257,7 @@ public sealed class SubAgentsProvider : AIContextProvider
new AIFunctionFactoryOptions
{
Name = "SubAgents_StartTask",
Description = "Start a sub-task on a named sub-agent. Returns an ID for the new task.",
Description = "Start a sub-task on a named sub-agent. Returns a confirmation message containing the task ID.",
SerializerOptions = serializerOptions,
}),
@@ -318,7 +315,7 @@ public sealed class SubAgentsProvider : AIContextProvider
new AIFunctionFactoryOptions
{
Name = "SubAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns the ID of the task that completed first.",
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
SerializerOptions = serializerOptions,
}),
@@ -386,6 +383,11 @@ public sealed class SubAgentsProvider : AIContextProvider
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == SubTaskStatus.Lost)
{
return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead.";
}
if (taskInfo.Status == SubTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
@@ -16,6 +16,10 @@ public sealed class SubAgentsProviderOptions
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
/// </summary>
/// <remarks>
/// Use the <c>{sub_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available sub agents.
/// </remarks>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use the sub-agent tools.
@@ -36,6 +36,8 @@ public sealed class TodoProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## Todo Items
You have access to a todo list for tracking work items.
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
Ask questions from the user where clarification is needed to create effective todos.
@@ -488,6 +488,200 @@ public class FileMemoryProviderTests
#endregion
#region Memory Index Tests
[Fact]
public async Task SaveFile_CreatesMemoryIndexAsync()
{
// 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",
}, session);
// Assert — memories.md should exist and contain the file entry.
string? index = await store.ReadFileAsync("memories.md");
Assert.NotNull(index);
Assert.Contains("**notes.md**", index);
}
[Fact]
public async Task SaveFile_WithDescription_IndexIncludesDescriptionAsync()
{
// 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"] = "Research data",
["description"] = "Key findings",
}, session);
// Assert
string? index = await store.ReadFileAsync("memories.md");
Assert.NotNull(index);
Assert.Contains("**research.md**: Key findings", index);
}
[Fact]
public async Task DeleteFile_UpdatesMemoryIndexAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var (tools, _, session) = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileMemory_SaveFile");
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Content",
}, session);
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "other.md",
["content"] = "Other",
}, session);
// Act
await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
}, session);
// Assert — index should only contain other.md
string? index = await store.ReadFileAsync("memories.md");
Assert.NotNull(index);
Assert.DoesNotContain("notes.md", index);
Assert.Contains("**other.md**", index);
}
[Fact]
public async Task MemoryIndex_CappedAt50EntriesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var (tools, _, session) = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileMemory_SaveFile");
// Act — save 55 files
for (int i = 0; i < 55; i++)
{
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = $"file{i:D3}.md",
["content"] = $"Content {i}",
}, session);
}
// Assert — index should have at most 50 entries
string? index = await store.ReadFileAsync("memories.md");
Assert.NotNull(index);
int entryCount = 0;
foreach (string line in index!.Split('\n'))
{
if (line.StartsWith("- **", StringComparison.Ordinal))
{
entryCount++;
}
}
Assert.Equal(50, entryCount);
}
[Fact]
public async Task ListFiles_HidesMemoryIndexAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var (tools, _, session) = await CreateToolsAsync(store);
var saveFile = GetTool(tools, "FileMemory_SaveFile");
var listFiles = GetTool(tools, "FileMemory_ListFiles");
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "notes.md",
["content"] = "Content",
}, session);
// Act
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
// Assert — memories.md should not appear in the listing
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
Assert.Single(entries);
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
}
[Fact]
public async Task ProvideAIContextAsync_InjectsMemoryIndexMessageAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
var provider = new FileMemoryProvider(store);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
// First, save a file via tool invocation to create the index.
#pragma warning disable MAAI001
var initContext = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext initResult = await provider.InvokingAsync(initContext);
var saveFile = GetTool(initResult.Tools!, "FileMemory_SaveFile");
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
{
["fileName"] = "research.md",
["content"] = "Data",
["description"] = "Research summary",
}, session);
// Act — invoke the provider again; it should now inject the memory index.
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
var messages = result.Messages!.ToList();
Assert.Single(messages);
Assert.Equal(ChatRole.User, messages[0].Role);
Assert.Contains("memory index", messages[0].Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("research.md", messages[0].Text);
}
[Fact]
public async Task ProvideAIContextAsync_NoFiles_NoMessageInjectedAsync()
{
// Arrange
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — no memories.md exists, so no message should be injected
Assert.Null(result.Messages);
}
#endregion
#region Helper Methods
private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
@@ -98,13 +98,10 @@ public class StorePathsTests
}
[Fact]
public void NormalizeRelativePath_WhitespaceOnlyFile_DoesNotThrowAsTraversal()
public void NormalizeRelativePath_WhitespaceOnlyFile_Throws()
{
// Act — whitespace characters are not path separators, so " " becomes a valid segment.
string result = StorePaths.NormalizeRelativePath(" ");
// Assert
Assert.Equal(" ", result);
// Act & Assert — whitespace-only paths are rejected as invalid file names.
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(" "));
}
#endregion
@@ -819,14 +819,14 @@ public class SubAgentsProviderTests
#region Options Tests
/// <summary>
/// Verify that custom instructions from options override the default instructions but agent list is still appended.
/// Verify that custom instructions from options override the default instructions but agent list is still injected via placeholder.
/// </summary>
[Fact]
public async Task CustomInstructions_OverridesDefaultInstructionsAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
const string CustomInstructions = "These are custom sub-agent instructions.";
const string CustomInstructions = "These are custom sub-agent instructions.\n{sub_agents}";
var options = new SubAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new SubAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
@@ -834,16 +834,16 @@ public class SubAgentsProviderTests
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom instructions replace default, agent list is appended
Assert.StartsWith(CustomInstructions, result.Instructions);
// Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder
Assert.Contains("These are custom sub-agent instructions.", result.Instructions);
Assert.Contains("Research", result.Instructions);
}
/// <summary>
/// Verify that default instructions contain tool names and agent names.
/// Verify that default instructions contain tool reference and agent names.
/// </summary>
[Fact]
public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync()
public async Task DefaultInstructions_ContainsToolReferenceAndAgentListAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
@@ -853,12 +853,8 @@ public class SubAgentsProviderTests
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions contain both tool usage guidance and agent list
Assert.Contains("SubAgents_StartTask", result.Instructions);
Assert.Contains("SubAgents_WaitForFirstCompletion", result.Instructions);
Assert.Contains("SubAgents_GetTaskResults", result.Instructions);
Assert.Contains("SubAgents_GetAllTasks", result.Instructions);
Assert.Contains("SubAgents_ContinueTask", result.Instructions);
// Assert — instructions contain tool usage guidance and agent list
Assert.Contains("SubAgents_*", result.Instructions);
Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("Research", result.Instructions);
Assert.Contains("Research agent", result.Instructions);