diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs index 8f35070bf6..c00b93b452 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs @@ -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 DownloadUriAsync( [Description("The URL to download")] string uri, CancellationToken cancellationToken = default) @@ -41,6 +41,15 @@ private static async Task DownloadUriAsync( 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); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 3615d76532..714f52c577 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -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 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 _sessionState; private readonly IReadOnlyList _modes; private readonly string _defaultMode; - private readonly string? _customInstructions; + private readonly string? _instructions; private readonly HashSet _validModeNames; private readonly string _modeNamesDisplay; private IReadOnlyList? _stateKeys; @@ -68,7 +82,7 @@ public AgentModeProvider(AgentModeProviderOptions? options = null) 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(StringComparer.Ordinal); var modeNamesList = new List(this._modes.Count); @@ -147,7 +161,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co { 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 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co return new ValueTask(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) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs index 8b0379be28..f65c80aba5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs @@ -17,9 +17,13 @@ public sealed class AgentModeProviderOptions /// /// Gets or sets custom instructions provided to the agent for using the mode tools. /// + /// + /// The instructions must contain a {available_modes} placeholder for the provider to inject the + /// currently available list of modes, and a {current_mode} placeholder to inject the currently + /// active mode. + /// /// - /// When (the default), the provider generates instructions dynamically - /// from the configured list. + /// When (the default), the provider uses a default set of instructions. /// public string? Instructions { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 0a6f8c9cdd..b16e9408fa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -4,6 +4,7 @@ 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 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont 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; } /// @@ -119,6 +140,11 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont [Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")] private async Task SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default) { + 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 @@ private async Task SaveFileAsync(string fileName, string content, string 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; } /// @@ -173,6 +202,7 @@ private async Task DeleteFileAsync(string fileName, CancellationToken ca 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 @@ private async Task> ListFilesAsync(CancellationToken cancell continue; } + if (IsInternalFile(file)) + { + continue; + } + string? fileDescription = null; string descFileName = GetDescriptionFileName(file); @@ -234,7 +269,20 @@ private async Task> SearchFilesAsync(string regexPattern, FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; IReadOnlyList results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false); - return new List(results); + + // Filter out internal files (description sidecars and memory index) so they stay hidden. + var filtered = new List(results.Count); + foreach (var result in results) + { + if (IsInternalFile(result.FileName)) + { + continue; + } + + filtered.Add(result); + } + + return filtered; } private AITool[] CreateTools() @@ -251,6 +299,56 @@ private AITool[] CreateTools() ]; } + /// + /// Rebuilds the memories.md index file by listing all user files in the working folder, + /// reading their companion description files, and writing a markdown summary capped at entries. + /// + private async Task RebuildMemoryIndexAsync(FileMemoryState state, CancellationToken cancellationToken) + { + IReadOnlyList 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 @@ private static string GetDescriptionFileName(string fileName) return fileName + DescriptionSuffix; } + /// + /// Returns if the file is an internal system file that should be hidden + /// from user-facing operations (description sidecars and the memory index). + /// + 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.). diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs index 4714993c45..98049de57f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs @@ -30,6 +30,16 @@ internal static class StorePaths /// 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) || diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs index f6c6daf592..254e082523 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs @@ -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 _agents; @@ -77,7 +74,7 @@ public SubAgentsProvider(IEnumerable agents, SubAgentsProviderOptions? 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( _ => new SubAgentState(), @@ -260,7 +257,7 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt 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 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt 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 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt 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."; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs index 06b9828e58..27a4c1530d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs @@ -16,6 +16,10 @@ public sealed class SubAgentsProviderOptions /// /// Gets or sets custom instructions provided to the agent for using the sub-agent tools. /// + /// + /// Use the {sub_agents} placeholder to allow the provider to inject + /// the formatted list of available sub agents. + /// /// /// When (the default), the provider uses built-in instructions /// that guide the agent on how to use the sub-agent tools. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index 336aac855e..3a26a07f0b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -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. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs index eaa3286ad1..a5341f802b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -488,6 +488,200 @@ public async Task SaveFile_DoubleDotsInFileName_AllowedAsync() #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(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().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().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? stateInitializer = null) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs index 20a45a3ac4..676b41ef5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs @@ -98,13 +98,10 @@ public void NormalizeRelativePath_EmptyFile_Throws() } [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(() => StorePaths.NormalizeRelativePath(" ")); } #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs index 42ee69907c..4f76d610b3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs @@ -819,14 +819,14 @@ public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync() #region Options Tests /// - /// 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. /// [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 async Task CustomInstructions_OverridesDefaultInstructionsAsync() // 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); } /// - /// Verify that default instructions contain tool names and agent names. + /// Verify that default instructions contain tool reference and agent names. /// [Fact] - public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync() + public async Task DefaultInstructions_ContainsToolReferenceAndAgentListAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); @@ -853,12 +853,8 @@ public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync() // 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);