mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
899394a58c
commit
e3f76618c5
@@ -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.
|
||||
|
||||
+194
@@ -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
|
||||
|
||||
+8
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user