Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ private static async Task<string> 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);
Comment thread
westey-m marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand All @@ -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;
Expand All @@ -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<string>(StringComparer.Ordinal);
var modeNamesList = new List<string>(this._modes.Count);
Expand Down Expand Up @@ -147,7 +161,7 @@ protected override ValueTask<AIContext> 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
{
Expand All @@ -171,22 +185,20 @@ protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext co
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ 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.
Comment thread
westey-m marked this conversation as resolved.
Outdated
Expand Down
125 changes: 113 additions & 12 deletions dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -99,11 +104,27 @@ protected override async ValueTask<AIContext> 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;
}

/// <summary>
Expand Down Expand Up @@ -135,9 +156,12 @@ private async Task<string> 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;
}

/// <summary>
Expand Down Expand Up @@ -173,6 +197,7 @@ private async Task<string> 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.";
}

Expand Down Expand Up @@ -204,6 +229,11 @@ private async Task<List<FileListEntry>> ListFilesAsync(CancellationToken cancell
continue;
}

if (IsInternalFile(file))
Comment thread
westey-m marked this conversation as resolved.
{
continue;
}

string? fileDescription = null;
string descFileName = GetDescriptionFileName(file);

Expand Down Expand Up @@ -234,7 +264,20 @@ private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern,
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()
Expand All @@ -251,6 +294,56 @@ private AITool[] CreateTools()
];
}

/// <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)
Comment thread
westey-m marked this conversation as resolved.
{
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;
}
Comment thread
westey-m marked this conversation as resolved.

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('.');
Expand All @@ -264,6 +357,14 @@ private static string GetDescriptionFileName(string fileName)
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.).
Expand Down
10 changes: 10 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -77,7 +74,7 @@ public SubAgentsProvider(IEnumerable<AIAgent> 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<SubAgentState>(
_ => new SubAgentState(),
Expand Down Expand Up @@ -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,
}),

Expand Down Expand Up @@ -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,
}),

Expand Down Expand Up @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading