Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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)
Expand All @@ -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);
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,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; }

Expand Down
130 changes: 118 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 All @@ -119,6 +140,11 @@ protected override async ValueTask<AIContext> 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<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);
Expand All @@ -135,9 +161,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 +202,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 +234,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 +269,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 +299,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 +362,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
Loading
Loading