chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,282 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that tracks the agent's operating mode (e.g., "plan" or "execute")
/// in the session state and provides tools for querying and switching modes.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="AgentModeProvider"/> enables agents to operate in distinct modes during long-running
/// complex tasks. The current mode is persisted in the session's <see cref="AgentSessionStateBag"/>
/// and is included in the instructions provided to the agent on each invocation.
/// </para>
/// <para>
/// The set of available modes is configurable via <see cref="AgentModeProviderOptions.Modes"/>.
/// By default, two modes are provided: <c>"plan"</c> (interactive planning) and <c>"execute"</c>
/// (autonomous execution).
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>mode_set</c> — Switch the agent's operating mode.</description></item>
/// <item><description><c>mode_get</c> — Retrieve the agent's current operating mode.</description></item>
/// </list>
/// </para>
/// <para>
/// Public helper methods <see cref="GetMode"/> and <see cref="SetMode"/> allow external code
/// to programmatically read and change the mode.
/// </para>
/// </remarks>
[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 mode_get tool to check your current operating mode.
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
You are currently operating in the {current_mode} mode.
### Mandatory Mode based Workflow
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
{available_modes}
""";
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.
Process to follow when in plan mode:
1. Analyze the request with the purpose of building a research plan.
2. Create a list of todo items.
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
4. Ask for clarifications from the user where needed.
1. Ask each clarification one by one.
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
3. Do not proceed until you have received all the needed clarifications.
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode (using the `mode_set` tool), and follow the steps for *Execute mode*.
"""),
new(
"execute",
"""
Determine the type of ask:
1. Simple question that doesn't require any further work to answer.
2. Any other work, including complex user request that requires a multi-step process to satisfy.
If 1. just answer the question directly.
If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback and follow the following process:
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
4. Mark tasks as completed as you finish them.
5. Continue working, thinking and calling tools until you have the research result for the user.
"""),
];
private readonly ProviderSessionState<AgentModeState> _sessionState;
private readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> _modes;
private readonly string _defaultMode;
private readonly string? _instructions;
private readonly HashSet<string> _validModeNames;
private readonly string _modeNamesDisplay;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="AgentModeProvider"/> class.
/// </summary>
/// <param name="options">Optional settings that control provider behavior. When <see langword="null"/>, defaults are used.</param>
public AgentModeProvider(AgentModeProviderOptions? options = null)
{
this._modes = options?.Modes ?? s_defaultModes;
if (this._modes.Count == 0)
{
throw new ArgumentException("At least one mode must be configured.", nameof(options));
}
this._instructions = options?.Instructions ?? DefaultInstructions;
this._validModeNames = new HashSet<string>(StringComparer.Ordinal);
var modeNamesList = new List<string>(this._modes.Count);
for (int i = 0; i < this._modes.Count; i++)
{
var mode = this._modes[i];
if (mode is null)
{
throw new ArgumentException($"Configured mode at index {i} must not be null.", nameof(options));
}
if (string.IsNullOrEmpty(mode.Name))
{
throw new ArgumentException($"Configured mode at index {i} must have a non-empty name.", nameof(options));
}
if (!this._validModeNames.Add(mode.Name))
{
throw new ArgumentException($"Configured modes contain a duplicate mode name \"{mode.Name}\".", nameof(options));
}
modeNamesList.Add(mode.Name);
}
this._modeNamesDisplay = string.Join("\", \"", modeNamesList);
this._defaultMode = options?.DefaultMode ?? modeNamesList[0];
if (!this._validModeNames.Contains(this._defaultMode))
{
throw new ArgumentException($"Default mode \"{this._defaultMode}\" is not in the configured modes list.", nameof(options));
}
this._sessionState = new ProviderSessionState<AgentModeState>(
_ => new AgentModeState { CurrentMode = this._defaultMode },
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Gets the current operating mode from the session state.
/// </summary>
/// <param name="session">The agent session to read the mode from.</param>
/// <returns>The current mode string.</returns>
public string GetMode(AgentSession? session)
{
return this._sessionState.GetOrInitializeState(session).CurrentMode;
}
/// <summary>
/// Sets the operating mode in the session state.
/// </summary>
/// <param name="session">The agent session to update the mode in.</param>
/// <param name="mode">The new mode to set.</param>
/// <exception cref="ArgumentException"><paramref name="mode"/> is not a configured mode.</exception>
public void SetMode(AgentSession? session, string mode)
{
this.ValidateMode(mode);
AgentModeState state = this._sessionState.GetOrInitializeState(session);
string previousMode = state.CurrentMode;
state.CurrentMode = mode;
if (!string.Equals(previousMode, mode, StringComparison.Ordinal))
{
state.PreviousModeForNotification = previousMode;
}
this._sessionState.SaveState(session, state);
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
AgentModeState state = this._sessionState.GetOrInitializeState(context.Session);
string instructions = this.BuildInstructions(state.CurrentMode);
var aiContext = new AIContext
{
Instructions = instructions,
Tools = this.CreateTools(state, context.Session),
};
// If the mode was changed externally (e.g., via /mode command), inject a notification message
// so the agent clearly sees the change rather than relying solely on the system instructions.
if (state.PreviousModeForNotification != null)
{
string previousMode = state.PreviousModeForNotification;
state.PreviousModeForNotification = null;
aiContext.Messages =
[
new ChatMessage(ChatRole.User, $"[Mode changed: The operating mode has been switched from \"{previousMode}\" to \"{state.CurrentMode}\". You must now adjust your behavior to match the \"{state.CurrentMode}\" mode.]"),
];
}
return new ValueTask<AIContext>(aiContext);
}
private string BuildInstructions(string currentMode)
{
var modesListBuilder = new StringBuilder();
foreach (var mode in this._modes)
{
modesListBuilder.AppendLine($"#### {mode.Name}");
modesListBuilder.AppendLine();
modesListBuilder.AppendLine(mode.Description.TrimEnd());
modesListBuilder.AppendLine();
}
var modesListText = modesListBuilder.ToString();
return new StringBuilder(this._instructions)
.Replace("{available_modes}", modesListText)
.Replace("{current_mode}", currentMode)
.ToString();
}
private void ValidateMode(string mode)
{
if (!this._validModeNames.Contains(mode))
{
throw new ArgumentException($"Invalid mode: \"{mode}\". Supported modes are: \"{this._modeNamesDisplay}\".", nameof(mode));
}
}
private AITool[] CreateTools(AgentModeState state, AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
(string mode) =>
{
this.ValidateMode(mode);
state.CurrentMode = mode;
this._sessionState.SaveState(session, state);
return $"Mode changed to \"{mode}\".";
},
new AIFunctionFactoryOptions
{
Name = "mode_set",
Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
() => state.CurrentMode,
new AIFunctionFactoryOptions
{
Name = "mode_get",
Description = "Get the agent's current operating mode.",
SerializerOptions = serializerOptions,
}),
];
}
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="AgentModeProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
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 uses a default set of instructions.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets the list of available modes the agent can operate in.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses two built-in modes:
/// <c>"plan"</c> (interactive planning) and <c>"execute"</c> (autonomous execution).
/// </value>
public IReadOnlyList<AgentMode>? Modes { get; set; }
/// <summary>
/// Gets or sets the initial mode for new sessions.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the first mode in the <see cref="Modes"/> list is used.
/// Must match the <see cref="AgentMode.Name"/> of one of the configured modes.
/// </value>
public string? DefaultMode { get; set; }
/// <summary>
/// Represents an agent operating mode with a name and description.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentMode
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentMode"/> class.
/// </summary>
/// <param name="name">The name of the mode.</param>
/// <param name="description">A description of when and how to use this mode.</param>
/// <exception cref="ArgumentNullException"><paramref name="name"/> or <paramref name="description"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> or <paramref name="description"/> is empty or whitespace.</exception>
public AgentMode(string name, string description)
{
this.Name = Throw.IfNullOrWhitespace(name);
this.Description = Throw.IfNullOrWhitespace(description);
}
/// <summary>
/// Gets the name of the mode.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets a description of when and how to use this mode.
/// </summary>
public string Description { get; }
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the state of the agent's operating mode, stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentModeState
{
/// <summary>
/// Gets or sets the current operating mode of the agent.
/// </summary>
[JsonPropertyName("currentMode")]
public string CurrentMode { get; set; } = "plan";
/// <summary>
/// Gets or sets the previous mode before the last external change, if a mode change notification is pending.
/// When non-null, indicates that the mode was changed externally and a notification should be injected.
/// </summary>
[JsonPropertyName("previousModeForNotification")]
public string? PreviousModeForNotification { get; set; }
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Holds non-serializable runtime references for in-flight background tasks within a single parent session.
/// </summary>
/// <remarks>
/// Properties are marked with <see cref="JsonIgnoreAttribute"/> because <see cref="Task{TResult}"/>
/// and <see cref="AgentSession"/> are not JSON-serializable. After deserialization (e.g., after a restart),
/// a fresh empty instance is created and any previously-running tasks are marked as
/// <see cref="BackgroundTaskStatus.Lost"/> by <see cref="BackgroundAgentsProvider"/>.
/// </remarks>
internal sealed class BackgroundAgentRuntimeState
{
/// <summary>
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
/// </summary>
[JsonIgnore]
public Dictionary<int, Task<AgentResponse>> InFlightTasks { get; } = [];
/// <summary>
/// Gets the mapping of task IDs to their background agent <see cref="AgentSession"/> instances,
/// needed for <c>ContinueTask</c>.
/// </summary>
[JsonIgnore]
public Dictionary<int, AgentSession> BackgroundTaskSessions { get; } = [];
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the serializable state of background tasks managed by the <see cref="BackgroundAgentsProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class BackgroundAgentState
{
/// <summary>
/// Gets or sets the next ID to assign to a new background task.
/// </summary>
[JsonPropertyName("nextTaskId")]
public int NextTaskId { get; set; } = 1;
/// <summary>
/// Gets the list of background task metadata entries.
/// </summary>
[JsonPropertyName("tasks")]
public List<BackgroundTaskInfo> Tasks { get; set; } = [];
}
@@ -0,0 +1,503 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to background agents asynchronously.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="BackgroundAgentsProvider"/> allows a parent agent to start background tasks on child agents,
/// wait for their completion, and retrieve results. Each background task runs in its own session and
/// executes concurrently.
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>background_agents_start_task</c> — Start a background task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>background_agents_wait_for_first_completion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>background_agents_get_task_results</c> — Retrieve the text output of a completed background task.</description></item>
/// <item><description><c>background_agents_get_all_tasks</c> — List all background tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>background_agents_continue_task</c> — Send follow-up input to a completed background task's session to resume work.</description></item>
/// <item><description><c>background_agents_clear_completed_task</c> — Remove a completed background task and release its session to free memory.</description></item>
/// </list>
/// </para>
/// <para>
/// <strong>Security considerations:</strong> The agents passed to the constructor are delegated
/// arbitrary work by the parent agent — the parent sends them text input (which may include content
/// derived from the parent's own untrusted context) and receives back whatever text they produce. A
/// compromised or malicious supplied agent (for example, one with a compromised system prompt, tools,
/// or upstream model) could exfiltrate that input to an external system, or return adversarial output
/// designed to influence the parent agent via indirect prompt injection once its result is retrieved.
/// Only supply agents you have vetted and trust with the data the parent may pass to them.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundAgentsProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## BackgroundAgents
You have access to background agents that can perform work on your behalf.
- Use the `background_agents_*` list of tools to start tasks on background agents and check their results.
- Creating a background task does not block, and background 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 background_agents_clear_completed_task to free memory, unless you plan to continue it with background_agents_continue_task.
{background_agents}
""";
private readonly Dictionary<string, AIAgent> _agents;
private readonly ProviderSessionState<BackgroundAgentState> _sessionState;
private readonly ProviderSessionState<BackgroundAgentRuntimeState> _runtimeSessionState;
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="BackgroundAgentsProvider"/> class.
/// </summary>
/// <param name="agents">
/// The collection of background agents available for delegation. <strong>Security:</strong> Each
/// supplied agent should be vetted and trusted, since it will receive text input from the parent
/// agent and its output is fed back into the parent's context — see the type-level security
/// considerations for details on the exfiltration and prompt-injection risks of untrusted agents.
/// </param>
/// <param name="options">Optional settings controlling the provider behavior.</param>
/// <exception cref="ArgumentNullException"><paramref name="agents"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">An agent has a null or empty name, or agent names are not unique.</exception>
public BackgroundAgentsProvider(IEnumerable<AIAgent> agents, BackgroundAgentsProviderOptions? options = null)
{
_ = Throw.IfNull(agents);
this._agents = ValidateAndBuildAgentDictionary(agents);
string baseInstructions = options?.Instructions ?? DefaultInstructions;
string agentListText = options?.AgentListBuilder is not null
? options.AgentListBuilder(this._agents)
: BuildDefaultAgentListText(this._agents);
this._instructions = baseInstructions.Replace("{background_agents}", agentListText);
this._sessionState = new ProviderSessionState<BackgroundAgentState>(
_ => new BackgroundAgentState(),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
this._runtimeSessionState = new ProviderSessionState<BackgroundAgentRuntimeState>(
_ => new BackgroundAgentRuntimeState(),
this.GetType().Name + "_Runtime",
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey, this._runtimeSessionState.StateKey];
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
BackgroundAgentState state = this._sessionState.GetOrInitializeState(context.Session);
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._instructions,
Tools = this.CreateTools(state, runtimeState, context.Session),
});
}
/// <summary>
/// Gets the background tasks for the specified session that have not yet completed (i.e., are still running).
/// </summary>
/// <remarks>
/// The status of in-flight tasks is refreshed before the result is computed, so tasks that have finished since the
/// last interaction are finalized and excluded. Only tasks whose <see cref="BackgroundTaskInfo.Status"/> is
/// <see cref="BackgroundTaskStatus.Running"/> are returned; <see cref="BackgroundTaskStatus.Completed"/>,
/// <see cref="BackgroundTaskStatus.Failed"/>, and <see cref="BackgroundTaskStatus.Lost"/> are all terminal and are
/// not included. The returned <see cref="BackgroundTaskInfo"/> instances are live references to internal state.
/// </remarks>
/// <param name="session">The agent session whose background tasks should be inspected.</param>
/// <returns>A read-only list of the background tasks that are still running.</returns>
public IReadOnlyList<BackgroundTaskInfo> GetIncompleteTasks(AgentSession? session)
{
BackgroundAgentState state = this._sessionState.GetOrInitializeState(session);
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session);
this.TryRefreshTaskState(state, runtimeState, session);
var incomplete = new List<BackgroundTaskInfo>();
foreach (BackgroundTaskInfo task in state.Tasks)
{
if (task.Status == BackgroundTaskStatus.Running)
{
incomplete.Add(task);
}
}
return incomplete;
}
/// <summary>
/// Validates the agent collection and builds a case-insensitive name dictionary.
/// </summary>
private static Dictionary<string, AIAgent> ValidateAndBuildAgentDictionary(IEnumerable<AIAgent> agents)
{
var dict = new Dictionary<string, AIAgent>(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new ArgumentException("All background agents must have a non-empty Name.", nameof(agents));
}
if (dict.ContainsKey(agent.Name))
{
throw new ArgumentException($"Duplicate background agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
}
dict[agent.Name] = agent;
}
if (dict.Count == 0)
{
throw new ArgumentException("At least one background agent must be provided.", nameof(agents));
}
return dict;
}
/// <summary>
/// Builds the default text listing available background agents and their descriptions.
/// </summary>
private static string BuildDefaultAgentListText(IReadOnlyDictionary<string, AIAgent> agents)
{
var sb = new StringBuilder();
sb.AppendLine("Available background agents:");
foreach (var kvp in agents)
{
sb.Append("- ").Append(kvp.Key);
if (!string.IsNullOrWhiteSpace(kvp.Value.Description))
{
sb.Append(": ").Append(kvp.Value.Description);
}
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>
/// Refreshes the status of in-flight tasks in the given state for the specified session.
/// </summary>
private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
{
bool changed = false;
foreach (BackgroundTaskInfo task in state.Tasks)
{
if (task.Status != BackgroundTaskStatus.Running)
{
continue;
}
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = BackgroundTaskStatus.Lost;
changed = true;
continue;
}
if (inFlight.IsCompleted)
{
FinalizeTask(task, inFlight, runtimeState);
changed = true;
}
}
if (changed)
{
this._sessionState.SaveState(session, state);
}
}
/// <summary>
/// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo.
/// </summary>
private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task<AgentResponse> completedTask, BackgroundAgentRuntimeState runtimeState)
{
if (completedTask.Status == TaskStatus.RanToCompletion)
{
taskInfo.Status = BackgroundTaskStatus.Completed;
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed
taskInfo.ResultText = completedTask.Result.Text;
#pragma warning restore VSTHRD002
}
else if (completedTask.IsFaulted)
{
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error";
}
else if (completedTask.IsCanceled)
{
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.ErrorText = "Task was canceled.";
}
runtimeState.InFlightTasks.Remove(taskInfo.Id);
}
private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
async (
[Description("The name of the background agent to delegate the task to.")] string agentName,
[Description("The request to pass to the background agent.")] string input,
[Description("A description of the task used to identify the task later.")] string description) =>
{
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
{
return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
}
int taskId = state.NextTaskId++;
var taskInfo = new BackgroundTaskInfo
{
Id = taskId,
AgentName = agentName,
Description = description,
Status = BackgroundTaskStatus.Running,
};
state.Tasks.Add(taskInfo);
// Create a dedicated session for this background task so it can be continued later.
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
runtimeState.BackgroundTaskSessions[taskId] = subSession;
this._sessionState.SaveState(session, state);
return $"Background task {taskId} started on agent '{agentName}'.";
},
new AIFunctionFactoryOptions
{
Name = "background_agents_start_task",
Description = "Start a background task on a named background agent. Returns a confirmation message containing the task ID.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async (List<int> taskIds) =>
{
if (taskIds.Count == 0)
{
return "Error: No task IDs provided.";
}
// Collect in-flight tasks matching the requested IDs (including already-completed ones,
// since Task.WhenAny returns immediately for completed tasks).
var waitableTasks = new List<(int Id, Task<AgentResponse> Task)>();
foreach (int id in taskIds)
{
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
{
waitableTasks.Add((id, inFlight));
}
}
if (waitableTasks.Count == 0)
{
// Refresh state to catch any that completed.
this.TryRefreshTaskState(state, runtimeState, session);
this._sessionState.SaveState(session, state);
// Check if any of the requested IDs are already complete.
BackgroundTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != BackgroundTaskStatus.Running);
if (alreadyComplete is not null)
{
return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}.";
}
return "Error: None of the specified task IDs correspond to running tasks.";
}
// Wait for the first one to complete.
Task completedTask = await Task.WhenAny(waitableTasks.Select(t => t.Task)).ConfigureAwait(false);
// Find which ID completed.
var completedEntry = waitableTasks.First(t => t.Task == completedTask);
// Finalize the completed task.
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
if (taskInfo is not null)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
this._sessionState.SaveState(session, state);
}
return $"Task {completedEntry.Id} finished with status: {taskInfo?.Status.ToString() ?? "Unknown"}.";
},
new AIFunctionFactoryOptions
{
Name = "background_agents_wait_for_first_completion",
Description = "Block until the first of the specified background tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
return taskInfo.Status switch
{
BackgroundTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
BackgroundTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
BackgroundTaskStatus.Lost => "Task state was lost (reference unavailable).",
BackgroundTaskStatus.Running => $"Task {taskId} is still running.",
_ => $"Task {taskId} has status: {taskInfo.Status}.",
};
},
new AIFunctionFactoryOptions
{
Name = "background_agents_get_task_results",
Description = "Get the text output of a background task by its ID. Returns the result text if complete, or status information if still running or failed.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
() =>
{
this.TryRefreshTaskState(state, runtimeState, session);
if (state.Tasks.Count == 0)
{
return "No tasks.";
}
var sb = new StringBuilder();
sb.AppendLine("Tasks:");
foreach (BackgroundTaskInfo task in state.Tasks)
{
sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description);
}
return sb.ToString();
},
new AIFunctionFactoryOptions
{
Name = "background_agents_get_all_tasks",
Description = "List all background tasks with their IDs, statuses, agent names, and descriptions.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId, string text) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == BackgroundTaskStatus.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 == BackgroundTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
}
if (!this._agents.TryGetValue(taskInfo.AgentName, out AIAgent? agent))
{
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
}
if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
{
return $"Error: Session for task {taskId} is no longer available.";
}
// Reset task state and start a new run on the existing session.
taskInfo.Status = BackgroundTaskStatus.Running;
taskInfo.ResultText = null;
taskInfo.ErrorText = null;
// Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment).
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
this._sessionState.SaveState(session, state);
return $"Task {taskId} continued with new input.";
},
new AIFunctionFactoryOptions
{
Name = "background_agents_continue_task",
Description = "Send follow-up input to a completed or failed background task to resume its work. The background task's session is preserved, so the agent retains conversational context.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
(int taskId) =>
{
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == BackgroundTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before clearing.";
}
// Remove the task from state.
state.Tasks.Remove(taskInfo);
// Clean up runtime references.
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.BackgroundTaskSessions.Remove(taskId);
this._sessionState.SaveState(session, state);
return $"Task {taskId} cleared.";
},
new AIFunctionFactoryOptions
{
Name = "background_agents_clear_completed_task",
Description = "Remove a completed or failed background task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
SerializerOptions = serializerOptions,
}),
];
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundAgentsProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the background agent tools.
/// </summary>
/// <remarks>
/// Use the <c>{background_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available background agents.
/// </remarks>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use the background agent tools.
/// The agent list is always appended after the instructions regardless of this setting.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a custom function that builds the agent list text to append to instructions.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard list of agent names and descriptions.
/// When set, this function receives the dictionary of available agents (keyed by name) and should return
/// a formatted string describing the available background agents.
/// </value>
public Func<IReadOnlyDictionary<string, AIAgent>, string>? AgentListBuilder { get; set; }
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the metadata and result of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundTaskInfo
{
/// <summary>
/// Gets or sets the unique identifier for this background task.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the agent that is executing this background task.
/// </summary>
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a description of what this background task is doing.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current status of this background task.
/// </summary>
[JsonPropertyName("status")]
public BackgroundTaskStatus Status { get; set; }
/// <summary>
/// Gets or sets the text result of the background task, populated when the task completes successfully.
/// </summary>
[JsonPropertyName("resultText")]
public string? ResultText { get; set; }
/// <summary>
/// Gets or sets the error message if the background task failed.
/// </summary>
[JsonPropertyName("errorText")]
public string? ErrorText { get; set; }
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the status of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum BackgroundTaskStatus
{
/// <summary>
/// The background task is currently running.
/// </summary>
Running,
/// <summary>
/// The background task completed successfully.
/// </summary>
Completed,
/// <summary>
/// The background task failed with an error.
/// </summary>
Failed,
/// <summary>
/// The background task's in-flight reference was lost (e.g., after a restart),
/// and its final state cannot be determined.
/// </summary>
Lost,
}
@@ -0,0 +1,447 @@
// Copyright (c) Microsoft. All rights reserved.
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;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that provides file access tools to an agent
/// for writing, reading, deleting, listing, searching, and editing files.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="FileAccessProvider"/> gives agents the ability to work with files
/// in a folder that the user has granted access to. Unlike <see cref="FileMemoryProvider"/>,
/// which provides session-scoped memory that may be isolated per session, <see cref="FileAccessProvider"/>
/// operates on a shared, persistent folder whose contents are visible across sessions and agents.
/// This makes it suitable for reading input data, writing output artifacts, and working with
/// files that have a lifetime beyond any single agent session.
/// </para>
/// <para>
/// File access is mediated through a <see cref="AgentFileStore"/> abstraction, allowing pluggable
/// backends (in-memory, local file system, remote blob storage, etc.).
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>file_access_write</c> — Write a file with the given name and content.</description></item>
/// <item><description><c>file_access_read</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_access_delete</c> — Delete a file by name.</description></item>
/// <item><description><c>file_access_ls</c> — List the direct child files and subdirectories of a directory.</description></item>
/// <item><description><c>file_access_grep</c> — Recursively search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_access_replace</c> — Replace occurrences of a substring within a file.</description></item>
/// <item><description><c>file_access_replace_lines</c> — Replace whole lines within a file.</description></item>
/// </list>
/// When <see cref="FileAccessProviderOptions.DisableWriteTools"/> is set, only the read-only tools
/// (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>) are exposed.
/// </para>
/// <para>
/// By default, all of these tools require approval: each is exposed as an <see cref="ApprovalRequiredAIFunction"/>.
/// Approval can be disabled per group via <see cref="FileAccessProviderOptions.DisableReadOnlyToolApproval"/>
/// (read, ls, and grep) and <see cref="FileAccessProviderOptions.DisableWriteToolApproval"/>
/// (write, delete, replace, and replace_lines).
/// </para>
/// <para>
/// To auto-approve these tools without prompting, use the <see cref="ToolApprovalAgent"/> and add one of the provided rules to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/>:
/// <list type="bullet">
/// <item><description>
/// <see cref="ReadOnlyToolsAutoApprovalRule"/> — auto-approves only the read-only tools (read, ls,
/// and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
/// </description></item>
/// <item><description>
/// <see cref="AllToolsAutoApprovalRule"/> — auto-approves every file access tool, including the tools that modify the store.
/// </description></item>
/// </list>
/// For example, to auto-approve all file access tools:
/// <code>
/// builder.UseToolApproval(new ToolApprovalAgentOptions
/// {
/// AutoApprovalRules = [FileAccessProvider.AllToolsAutoApprovalRule],
/// });
/// </code>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAccessProvider : AIContextProvider, IDisposable
{
/// <summary>The name of the tool that writes a file.</summary>
public const string WriteToolName = "file_access_write";
/// <summary>The name of the tool that reads a file.</summary>
public const string ReadFileToolName = "file_access_read";
/// <summary>The name of the tool that deletes a file.</summary>
public const string DeleteFileToolName = "file_access_delete";
/// <summary>The name of the tool that lists the files and subdirectories in a directory.</summary>
public const string LsToolName = "file_access_ls";
/// <summary>The name of the tool that searches file contents.</summary>
public const string GrepToolName = "file_access_grep";
/// <summary>The name of the tool that replaces occurrences of a substring within a file.</summary>
public const string ReplaceToolName = "file_access_replace";
/// <summary>The name of the tool that replaces whole lines within a file.</summary>
public const string ReplaceLinesToolName = "file_access_replace_lines";
/// <summary>The names of the tools that only read from (never modify) the file store.</summary>
private static readonly HashSet<string> s_readOnlyToolNames = new(StringComparer.Ordinal)
{
ReadFileToolName,
LsToolName,
GrepToolName,
};
/// <summary>The names of all tools exposed by this provider.</summary>
private static readonly HashSet<string> s_allToolNames = new(StringComparer.Ordinal)
{
WriteToolName,
ReadFileToolName,
DeleteFileToolName,
LsToolName,
GrepToolName,
ReplaceToolName,
ReplaceLinesToolName,
};
private const string DefaultInstructions =
"""
## File Access
You have access to a shared file storage area via the `file_access_*` tools for reading, writing, and managing files.
These files persist beyond the current session and may be shared across sessions or agents.
Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with.
- Never delete or overwrite existing files unless the user has explicitly asked you to do so.
- Files may be organized into subdirectories. Use `file_access_ls` to explore the tree level by level,
or `file_access_grep` to search file contents recursively across the whole store.
- To make small edits to an existing file, prefer `file_access_replace` (substring replacement) or
`file_access_replace_lines` (whole-line replacement) over rewriting the whole file.
""";
private readonly AgentFileStore _fileStore;
private readonly string _instructions;
private readonly bool _disableWriteTools;
private readonly bool _disableReadOnlyToolApproval;
private readonly bool _disableWriteToolApproval;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private AITool[]? _tools;
/// <summary>
/// Initializes a new instance of the <see cref="FileAccessProvider"/> class.
/// </summary>
/// <param name="fileStore">
/// The file store implementation used for storage operations.
/// The store should already be scoped to the desired folder or storage location.
/// </param>
/// <param name="options">Optional settings that control provider behavior. When <see langword="null"/>, defaults are used.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStore"/> is <see langword="null"/>.</exception>
public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? options = null)
{
Throw.IfNull(fileStore);
this._fileStore = fileStore;
this._instructions = options?.Instructions ?? DefaultInstructions;
this._disableWriteTools = options?.DisableWriteTools ?? false;
this._disableReadOnlyToolApproval = options?.DisableReadOnlyToolApproval ?? false;
this._disableWriteToolApproval = options?.DisableWriteToolApproval ?? false;
}
/// <summary>
/// Gets an auto-approval rule that approves the read-only file access tools
/// (<see cref="ReadFileToolName"/>, <see cref="LsToolName"/>, and <see cref="GrepToolName"/>).
/// </summary>
/// <remarks>
/// <para>
/// By default, the tools exposed by <see cref="FileAccessProvider"/> require approval. Add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve only the tools
/// that read from the file store, while still prompting for tools that modify it
/// (<see cref="WriteToolName"/>, <see cref="DeleteFileToolName"/>, <see cref="ReplaceToolName"/>,
/// and <see cref="ReplaceLinesToolName"/>).
/// </para>
/// <para>
/// The rule matches on the tool name, returning <see langword="true"/> for read-only file access tools
/// and <see langword="false"/> for all other tool calls so that subsequent rules continue to be evaluated.
/// </para>
/// </remarks>
public static Func<FunctionCallContent, ValueTask<bool>> ReadOnlyToolsAutoApprovalRule { get; } =
functionCall => new ValueTask<bool>(s_readOnlyToolNames.Contains(functionCall.Name));
/// <summary>
/// Gets an auto-approval rule that approves all file access tools, including the tools that modify the
/// file store (<see cref="WriteToolName"/>, <see cref="DeleteFileToolName"/>, <see cref="ReplaceToolName"/>,
/// and <see cref="ReplaceLinesToolName"/>).
/// </summary>
/// <remarks>
/// <para>
/// By default, the tools exposed by <see cref="FileAccessProvider"/> require approval. Add this rule to
/// <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve every file access
/// tool without prompting the user.
/// </para>
/// <para>
/// The rule matches on the tool name, returning <see langword="true"/> for any file access tool
/// and <see langword="false"/> for all other tool calls so that subsequent rules continue to be evaluated.
/// </para>
/// </remarks>
public static Func<FunctionCallContent, ValueTask<bool>> AllToolsAutoApprovalRule { get; } =
functionCall => new ValueTask<bool>(s_allToolNames.Contains(functionCall.Name));
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => [];
/// <summary>
/// Releases the resources used by the <see cref="FileAccessProvider"/>.
/// </summary>
public void Dispose()
{
this._writeLock.Dispose();
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._instructions,
Tools = this._tools ??= this.CreateTools(),
});
}
/// <summary>
/// Write a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.
/// </summary>
/// <param name="fileName">The name of the file to write.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="overwrite">Whether to overwrite the file if it already exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message.</returns>
[Description("Write a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.")]
private async Task<string> WriteAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false))
{
return $"File '{fileName}' already exists. To replace it, write again with overwrite set to true.";
}
await this._fileStore.WriteAsync(path, content, cancellationToken).ConfigureAwait(false);
}
finally
{
this._writeLock.Release();
}
return $"File '{fileName}' written.";
}
/// <summary>
/// Read the content of a file by name. Returns the file content or a message indicating the file was not found.
/// </summary>
/// <param name="fileName">The name of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")]
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}
/// <summary>
/// Delete a file by name.
/// </summary>
/// <param name="fileName">The name of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation or not-found message.</returns>
[Description("Delete a file by name.")]
private async Task<string> DeleteAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
bool deleted = await this._fileStore.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// List the direct child files and subdirectories of a directory. Omit <paramref name="directory"/> (or pass an empty string)
/// to list the store root. Optionally filter entries with a glob pattern.
/// </summary>
/// <param name="directory">The relative directory path to list. Omit or pass an empty string to list the store root.</param>
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against entry names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of entries, each with a name and a type of "file" or "directory" (subdirectories first).</returns>
[Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a glob_pattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")]
private async Task<List<FileStoreEntry>> LsAsync(string? directory = null, string? globPattern = null, CancellationToken cancellationToken = default)
{
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory!;
IReadOnlyList<FileStoreEntry> entries = await this._fileStore.ListChildrenAsync(target, cancellationToken).ConfigureAwait(false);
Matcher? matcher = string.IsNullOrWhiteSpace(globPattern) ? null : StorePaths.CreateGlobMatcher(globPattern!);
return entries.Where(entry => StorePaths.MatchesGlob(entry.Name, matcher)).ToList();
}
/// <summary>
/// Replace occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in a file.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="oldString">The substring to find and replace.</param>
/// <param name="newString">The replacement text.</param>
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
(string newContent, int count) = FileEditor.ApplyReplace(content, oldString, newString, replaceAll);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {count} occurrence(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Replace lines in a file. Provide a list of edits, each with a 1-based line number and the literal
/// replacement text; an empty replacement deletes the line.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string path = StorePaths.NormalizeRelativePath(fileName);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
string newContent = FileEditor.ApplyReplaceLines(content, edits);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {edits.Count} line(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Search the contents of files in the store (recursively) using a regular expression pattern (case-insensitive).
/// Optionally restrict to a base directory and/or filter which files to search using a glob pattern.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="globPattern">An optional glob pattern to filter which files to search, matched against each file's path relative to the search directory. Leave empty or omit to search all files.</param>
/// <param name="directory">An optional base directory (relative path) to restrict the search to. Leave empty or omit to search the whole store.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results whose file names are paths relative to the store root.</returns>
[Description(
"""
Search the contents of files in the store (recursively, across all subdirectories) using a regular expression pattern (case-insensitive).
Optionally restrict the search to a base directory (relative path), and filter which files to search using a glob pattern matched against each file's path relative to that directory:
- '*' matches within a single path segment
- '**' matches across subdirectories, so use \"**/*.md\" to match markdown files at any depth, or \"reports/**\" to restrict the search to the 'reports' subtree.
Returns matching results whose file names are paths relative to the store root (usable with file_access_read), along with snippets and matching lines with line numbers.
""")]
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, string? directory = null, CancellationToken cancellationToken = default)
{
string? pattern = string.IsNullOrWhiteSpace(globPattern) ? null : globPattern;
string target = StorePaths.NormalizeRelativePath(directory ?? string.Empty, isDirectory: true);
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchAsync(target, regexPattern, pattern, recursive: true, cancellationToken).ConfigureAwait(false);
// store.SearchAsync returns FileName relative to the searched directory; re-root each result to the
// store root so the names compose directly with file_access_read/replace/delete.
string prefix = target;
if (prefix.Length == 0)
{
return new List<FileSearchResult>(results);
}
var rerooted = new List<FileSearchResult>(results.Count);
foreach (FileSearchResult result in results)
{
rerooted.Add(new FileSearchResult
{
FileName = $"{prefix}/{result.FileName}",
Snippet = result.Snippet,
MatchingLines = result.MatchingLines,
});
}
return rerooted;
}
private AITool[] CreateTools()
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
// Read-only and store-modifying tools require approval by default. Approval can be disabled
// per group via FileAccessProviderOptions.DisableReadOnlyToolApproval and DisableWriteToolApproval;
// otherwise callers can use the ReadOnlyToolsAutoApprovalRule or AllToolsAutoApprovalRule with the
// ToolApprovalAgent to automatically approve these tools.
bool readOnlyRequiresApproval = !this._disableReadOnlyToolApproval;
bool writeRequiresApproval = !this._disableWriteToolApproval;
var tools = new List<AITool>
{
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
};
if (!this._disableWriteTools)
{
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.WriteAsync, new AIFunctionFactoryOptions { Name = WriteToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.DeleteAsync, new AIFunctionFactoryOptions { Name = DeleteFileToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReplaceAsync, new AIFunctionFactoryOptions { Name = ReplaceToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
tools.Add(WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReplaceLinesAsync, new AIFunctionFactoryOptions { Name = ReplaceLinesToolName, SerializerOptions = serializerOptions }), writeRequiresApproval));
}
return tools.ToArray();
}
private static AITool WrapWithApprovalIfRequired(AIFunction function, bool requireApproval)
=> requireApproval ? new ApprovalRequiredAIFunction(function) : function;
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="FileAccessProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAccessProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the file access tools.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use file storage effectively.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the tools that modify the file store are disabled.
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), all tools are exposed. When <see langword="true"/>,
/// only the read-only tools (<c>file_access_read</c>, <c>file_access_ls</c>, and <c>file_access_grep</c>)
/// are exposed; the tools that modify the store (<c>file_access_write</c>, <c>file_access_delete</c>,
/// <c>file_access_replace</c>, and <c>file_access_replace_lines</c>) are hidden.
/// </value>
public bool DisableWriteTools { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the read-only file access tools
/// (<see cref="FileAccessProvider.ReadFileToolName"/>, <see cref="FileAccessProvider.LsToolName"/>,
/// and <see cref="FileAccessProvider.GrepToolName"/>).
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), these tools require approval before invocation.
/// When <see langword="true"/>, they can be invoked without approval.
/// When approval is required, auto-approval rules (e.g. <see cref="FileAccessProvider.ReadOnlyToolsAutoApprovalRule"/>
/// or <see cref="FileAccessProvider.AllToolsAutoApprovalRule"/>) can also be used to automatically approve calls.
/// </remarks>
public bool DisableReadOnlyToolApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether approval is disabled for the tools that modify the file store
/// (<see cref="FileAccessProvider.WriteToolName"/>, <see cref="FileAccessProvider.DeleteFileToolName"/>,
/// <see cref="FileAccessProvider.ReplaceToolName"/>, and <see cref="FileAccessProvider.ReplaceLinesToolName"/>).
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), these tools require approval before invocation.
/// When <see langword="true"/>, they can be invoked without approval.
/// When approval is required, the <see cref="FileAccessProvider.AllToolsAutoApprovalRule"/> can also be used
/// to automatically approve calls.
/// This setting has no effect when <see cref="DisableWriteTools"/> is <see langword="true"/>, since the
/// tools that modify the store are not exposed in that case.
/// </remarks>
public bool DisableWriteToolApproval { get; set; }
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a file entry returned by the <see cref="FileMemoryProvider"/> list (ls) tool,
/// containing the file name, its entry type, and an optional description.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileListEntry
{
/// <summary>
/// Gets or sets the name of the file.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the entry type. Memory entries are always <see cref="FileStoreEntry.File"/>.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = FileStoreEntry.File;
/// <summary>
/// Gets or sets the description of the file, or <see langword="null"/> if no description is available.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
}
@@ -0,0 +1,566 @@
// Copyright (c) Microsoft. All rights reserved.
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;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that provides file-based memory tools to an agent
/// for storing, retrieving, modifying, listing, deleting, and searching files.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="FileMemoryProvider"/> enables agents to persist information across interactions
/// using a file-based storage model. Each memory is stored as an individual file with a meaningful name.
/// For large files, a companion description file (suffixed with <c>_description.md</c>) can be stored
/// alongside the main file to provide a summary.
/// </para>
/// <para>
/// File access is mediated through a <see cref="AgentFileStore"/> abstraction, allowing pluggable
/// backends (in-memory, local file system, remote blob storage, etc.).
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>file_memory_write</c> — Write a memory file with the given name, content, and an optional description.</description></item>
/// <item><description><c>file_memory_read</c> — Read the content of a file by name.</description></item>
/// <item><description><c>file_memory_delete</c> — Delete a file by name.</description></item>
/// <item><description><c>file_memory_ls</c> — List all files with their descriptions (if available).</description></item>
/// <item><description><c>file_memory_grep</c> — Search file contents using a regular expression pattern.</description></item>
/// <item><description><c>file_memory_replace</c> — Replace occurrences of a substring within a memory file.</description></item>
/// <item><description><c>file_memory_replace_lines</c> — Replace whole lines within a memory file.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileMemoryProvider : AIContextProvider, IDisposable
{
/// <summary>The name of the tool that writes a memory file.</summary>
public const string WriteToolName = "file_memory_write";
/// <summary>The name of the tool that reads a memory file.</summary>
public const string ReadFileToolName = "file_memory_read";
/// <summary>The name of the tool that deletes a memory file.</summary>
public const string DeleteFileToolName = "file_memory_delete";
/// <summary>The name of the tool that lists the memory files.</summary>
public const string LsToolName = "file_memory_ls";
/// <summary>The name of the tool that searches memory file contents.</summary>
public const string GrepToolName = "file_memory_grep";
/// <summary>The name of the tool that replaces occurrences of a substring within a memory file.</summary>
public const string ReplaceToolName = "file_memory_replace";
/// <summary>The name of the tool that replaces whole lines within a memory file.</summary>
public const string ReplaceLinesToolName = "file_memory_replace_lines";
private const string DescriptionSuffix = "_description.md";
private const string MemoryIndexFileName = "memories.md";
private const int MaxIndexEntries = 50;
private const string DefaultInstructions =
"""
## File Based Memory
You have access to a session-scoped, file-based memory system via the `file_memory_*` tools for storing and retrieving information across interactions.
These files act as your working memory for the current session and are isolated from other sessions.
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 writing a file to help with future discovery.
- Before starting new tasks, use file_memory_ls and file_memory_grep to check for relevant existing memories to avoid duplicate work.
- Keep memories up-to-date by overwriting files when information changes, or by using file_memory_replace and file_memory_replace_lines to make small edits.
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
write 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;
private readonly ProviderSessionState<FileMemoryState> _sessionState;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
private AITool[]? _tools;
/// <summary>
/// Initializes a new instance of the <see cref="FileMemoryProvider"/> class.
/// </summary>
/// <param name="fileStore">The file store implementation used for storage operations.</param>
/// <param name="stateInitializer">
/// An optional function that initializes the <see cref="FileMemoryState"/> for a new session.
/// Use this to customize the working folder (e.g., per-user or per-session subfolders).
/// When <see langword="null"/>, the default initializer creates state with an empty working folder.
/// </param>
/// <param name="options">Optional settings that control provider behavior. When <see langword="null"/>, defaults are used.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStore"/> is <see langword="null"/>.</exception>
public FileMemoryProvider(AgentFileStore fileStore, Func<AgentSession?, FileMemoryState>? stateInitializer = null, FileMemoryProviderOptions? options = null)
{
Throw.IfNull(fileStore);
this._fileStore = fileStore;
this._instructions = options?.Instructions ?? DefaultInstructions;
this._sessionState = new ProviderSessionState<FileMemoryState>(
stateInitializer ?? (_ => new FileMemoryState()),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Releases the resources used by the <see cref="FileMemoryProvider"/>.
/// </summary>
public void Dispose()
{
this._writeLock.Dispose();
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(context.Session);
// Ensure the working folder exists in the store.
if (!string.IsNullOrEmpty(state.WorkingFolder))
{
await this._fileStore.CreateDirectoryAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
}
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.ReadAsync(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 written. " +
"You can read any of these files using the file_memory_read tool.\n\n" +
indexContent),
];
}
return aiContext;
}
/// <summary>
/// Write 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.
/// </summary>
/// <param name="fileName">The name of the file to write.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="description">An optional description of the file contents for discovery. Leave empty or omit to skip.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message.</returns>
[Description("Write 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 future discovery.")]
private async Task<string> WriteAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await this._fileStore.WriteAsync(path, content, cancellationToken).ConfigureAwait(false);
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(normalized));
if (!string.IsNullOrWhiteSpace(description))
{
await this._fileStore.WriteAsync(descPath, description!, cancellationToken).ConfigureAwait(false);
}
else
{
// Remove any stale description file when no description is provided.
await this._fileStore.DeleteAsync(descPath, cancellationToken).ConfigureAwait(false);
}
string result = string.IsNullOrWhiteSpace(description)
? $"File '{fileName}' written."
: $"File '{fileName}' written with description.";
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return result;
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Read the content of a memory file by name.
/// Returns the file content or a message indicating the file was not found.
/// </summary>
/// <param name="fileName">The name of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content or a not-found message.</returns>
[Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")]
private async Task<string> ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
return content ?? $"File '{fileName}' not found.";
}
/// <summary>
/// Delete a memory file by name. Also removes its companion description file if one exists.
/// </summary>
/// <param name="fileName">The name of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation or not-found message.</returns>
[Description("Delete a memory file by name. Also removes its companion description file if one exists.")]
private async Task<string> DeleteAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
bool deleted = await this._fileStore.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
// Also delete companion description file if it exists.
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(normalized));
await this._fileStore.DeleteAsync(descPath, cancellationToken).ConfigureAwait(false);
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// List all memory files with their descriptions (if available). Description files are not shown separately.
/// </summary>
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against file names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file entries with names and optional descriptions.</returns>
[Description("List all memory files with their descriptions (if available). Optionally filter file names with a glob_pattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")]
private async Task<List<FileListEntry>> LsAsync(string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
IReadOnlyList<FileStoreEntry> children = await this._fileStore.ListChildrenAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
var fileNames = children
.Where(c => string.Equals(c.Type, FileStoreEntry.File, StringComparison.Ordinal))
.Select(c => c.Name)
.ToList();
var availableFiles = new HashSet<string>(fileNames, StringComparer.OrdinalIgnoreCase);
var matcher = string.IsNullOrWhiteSpace(globPattern) ? null : StorePaths.CreateGlobMatcher(globPattern!);
var entries = new List<FileListEntry>();
foreach (string file in fileNames)
{
if (IsInternalFile(file))
{
continue;
}
if (!StorePaths.MatchesGlob(file, matcher))
{
continue;
}
string? fileDescription = null;
string descFileName = GetDescriptionFileName(file);
if (availableFiles.Contains(descFileName))
{
string descPath = CombinePaths(state.WorkingFolder, descFileName);
fileDescription = await this._fileStore.ReadAsync(descPath, cancellationToken).ConfigureAwait(false);
}
entries.Add(new FileListEntry { Name = file, Type = FileStoreEntry.File, Description = fileDescription });
}
return entries;
}
/// <summary>
/// Replace occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in a memory file.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="oldString">The substring to find and replace.</param>
/// <param name="newString">The replacement text.</param>
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
(string newContent, int count) = FileEditor.ApplyReplace(content, oldString, newString, replaceAll);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {count} occurrence(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Replace lines in a memory file. Provide a list of edits, each with a 1-based line number and the
/// literal replacement text; an empty replacement deletes the line.
/// </summary>
/// <param name="fileName">The name of the file to modify.</param>
/// <param name="edits">The list of 1-based line numbers and their literal replacement text.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of lines replaced, or a failure message.</returns>
[Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit> edits, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
ValidateMemoryFileName(normalized, fileName);
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string path = ResolvePath(state.WorkingFolder, normalized);
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return $"File '{fileName}' not found.";
}
string newContent = FileEditor.ApplyReplaceLines(content, edits);
await this._fileStore.WriteAsync(path, newContent, cancellationToken).ConfigureAwait(false);
return $"Replaced {edits.Count} line(s) in '{fileName}'.";
}
finally
{
this._writeLock.Release();
}
}
/// <summary>
/// Search memory file contents using a regular expression pattern (case-insensitive).
/// Optionally filter which files to search using a glob pattern.
/// Returns matching file names, content snippets, and matching lines with line numbers.
/// </summary>
/// <param name="regexPattern">A regular expression pattern to match against file contents (case-insensitive).</param>
/// <param name="globPattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
[Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob_pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")]
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
string? pattern = string.IsNullOrWhiteSpace(globPattern) ? null : globPattern;
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchAsync(state.WorkingFolder, regexPattern, pattern, recursive: false, cancellationToken).ConfigureAwait(false);
// 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()
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(this.WriteAsync, new AIFunctionFactoryOptions { Name = WriteToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.DeleteAsync, new AIFunctionFactoryOptions { Name = DeleteFileToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReplaceAsync, new AIFunctionFactoryOptions { Name = ReplaceToolName, SerializerOptions = serializerOptions }),
AIFunctionFactory.Create(this.ReplaceLinesAsync, new AIFunctionFactoryOptions { Name = ReplaceLinesToolName, SerializerOptions = serializerOptions }),
];
}
/// <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<FileStoreEntry> children = await this._fileStore.ListChildrenAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
// Sort deterministically so the index is stable across runs and platforms.
var sortedFiles = children
.Where(c => string.Equals(c.Type, FileStoreEntry.File, StringComparison.Ordinal))
.Select(c => c.Name)
.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 descFileName = GetDescriptionFileName(file);
string descPath = CombinePaths(state.WorkingFolder, descFileName);
string? description = await this._fileStore.ReadAsync(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.WriteAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
private static string GetDescriptionFileName(string fileName)
{
int extIndex = fileName.LastIndexOf('.');
if (extIndex > 0)
{
#pragma warning disable CA1845 // Use span-based 'string.Concat' — not available on all target frameworks
return fileName.Substring(0, extIndex) + DescriptionSuffix;
#pragma warning restore CA1845
}
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);
/// <summary>
/// Returns <see langword="true"/> if the normalized file name points into a subdirectory.
/// File memory is a flat, session-scoped space, so nested names are rejected up front.
/// </summary>
private static bool IsNestedPath(string normalizedFileName) =>
normalizedFileName.IndexOf('/') >= 0;
/// <summary>
/// Validates that a normalized memory file name is acceptable for write operations,
/// throwing <see cref="ArgumentException"/> when it points into a subdirectory or is
/// reserved for internal use.
/// </summary>
/// <param name="normalized">The normalized file name.</param>
/// <param name="fileName">The original file name, used for the <see cref="ArgumentException"/> parameter name.</param>
private static void ValidateMemoryFileName(string normalized, string fileName)
{
if (IsNestedPath(normalized))
{
throw new ArgumentException(
"Memory files must not be written into a subdirectory. Please choose a flat file name without path separators.",
nameof(fileName));
}
if (IsInternalFile(normalized))
{
throw new ArgumentException(
"The provided file name is reserved by the system for internal use. Please choose a different file name.",
nameof(fileName));
}
}
private static string ResolvePath(string workingFolder, string fileName)
{
// Validate and normalize the file name (rejects rooted, traversal, empty, etc.).
// Only fileName needs validation — workingFolder is developer-provided and trusted.
string normalizedFileName = StorePaths.NormalizeRelativePath(fileName);
string normalizedWorkingFolder = workingFolder.Replace('\\', '/');
return CombinePaths(normalizedWorkingFolder, normalizedFileName);
}
private static string CombinePaths(string basePath, string relativePath)
{
if (string.IsNullOrEmpty(basePath))
{
return relativePath;
}
if (string.IsNullOrEmpty(relativePath))
{
return basePath;
}
return basePath.TrimEnd('/') + "/" + relativePath.TrimStart('/');
}
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="FileMemoryProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileMemoryProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the file memory tools.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use file-based memory effectively.
/// </value>
public string? Instructions { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the state of the <see cref="FileMemoryProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileMemoryState
{
/// <summary>
/// Gets or sets the working folder path for this session, relative to the store root.
/// </summary>
[JsonPropertyName("workingFolder")]
public string WorkingFolder { get; set; } = string.Empty;
}
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for file storage operations.
/// </summary>
/// <remarks>
/// <para>
/// All paths are relative to an implementation-defined root. Implementations may map these
/// paths to a local file system, in-memory store, remote blob storage, or other mechanisms.
/// </para>
/// <para>
/// Paths use forward slashes as separators and must not escape the root (e.g., via <c>..</c> segments).
/// It is up to each implementation to ensure that this is enforced.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentFileStore
{
/// <summary>
/// Writes content to a file, creating or overwriting it.
/// </summary>
/// <param name="path">The relative path of the file to write.</param>
/// <param name="content">The content to write to the file.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public abstract Task WriteAsync(string path, string content, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the content of a file.
/// </summary>
/// <param name="path">The relative path of the file to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The file content, or <see langword="null"/> if the file does not exist.</returns>
public abstract Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a file.
/// </summary>
/// <param name="path">The relative path of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the file was deleted; <see langword="false"/> if it did not exist.</returns>
public abstract Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default);
/// <summary>
/// Lists the direct children (files and subdirectories) of a directory.
/// </summary>
/// <param name="directory">The relative path of the directory to list. Use an empty string for the root.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// A list of the direct children of the specified directory as <see cref="FileStoreEntry"/> instances.
/// Subdirectories are listed before files.
/// </returns>
public abstract Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether a file exists.
/// </summary>
/// <param name="path">The relative path of the file to check.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the file exists; otherwise, <see langword="false"/>.</returns>
public abstract Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for files whose content matches a regular expression pattern.
/// </summary>
/// <param name="directory">The relative path of the directory to search. Use an empty string for the root.</param>
/// <param name="regexPattern">
/// A regular expression pattern to match against file contents. The pattern is matched case-insensitively.
/// For example, <c>"error|warning"</c> matches lines containing "error" or "warning".
/// </param>
/// <param name="globPattern">
/// An optional glob pattern to filter which files are searched (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// When <see langword="null"/>, all files are searched.
/// Uses standard glob syntax from <see cref="Matcher"/>, matched against each file's path relative to
/// <paramref name="directory"/>. Use <c>**</c> to match across subdirectories (e.g., <c>"**/*.md"</c>).
/// </param>
/// <param name="recursive">
/// When <see langword="true"/>, all descendant files of <paramref name="directory"/> are searched.
/// When <see langword="false"/> (default), only the direct children of <paramref name="directory"/> are searched.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// A list of search results. Each result's <see cref="FileSearchResult.FileName"/> is the matching file's
/// path relative to <paramref name="directory"/>.
/// </returns>
public abstract Task<IReadOnlyList<FileSearchResult>> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default);
/// <summary>
/// Ensures a directory exists, creating it if necessary.
/// </summary>
/// <param name="path">The relative path of the directory to create.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal helpers shared by <see cref="FileAccessProvider"/> and <see cref="FileMemoryProvider"/>
/// for the <c>replace</c> and <c>replace_lines</c> tools.
/// </summary>
internal static class FileEditor
{
/// <summary>
/// Replaces occurrences of <paramref name="oldString"/> with <paramref name="newString"/> in
/// <paramref name="content"/>, returning the new content and the number of replacements made.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="oldString"/> is empty, is not found, or occurs more than once
/// while <paramref name="replaceAll"/> is <see langword="false"/>.
/// </exception>
internal static (string Content, int Count) ApplyReplace(string content, string oldString, string newString, bool replaceAll)
{
if (string.IsNullOrEmpty(oldString))
{
throw new ArgumentException("old_string must not be empty.");
}
int count = CountOccurrences(content, oldString);
if (count == 0)
{
throw new ArgumentException($"old_string not found: '{oldString}'.");
}
if (count > 1 && !replaceAll)
{
throw new ArgumentException(
$"old_string occurs {count} times; pass replace_all=true to replace all, " +
"or provide a more specific old_string.");
}
#if NET8_0_OR_GREATER
return (content.Replace(oldString, newString, StringComparison.Ordinal), count);
#else
return (content.Replace(oldString, newString), count);
#endif
}
/// <summary>
/// Applies literal (1-based) line replacements to <paramref name="content"/>.
/// </summary>
/// <remarks>
/// Each edit's <see cref="FileLineEdit.NewLine"/> is treated as the literal replacement text for the
/// targeted line, including any trailing newline the caller wants to keep — the editor does not add
/// one. An empty <see cref="FileLineEdit.NewLine"/> deletes the line entirely, including its line break.
/// </remarks>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="edits"/> is empty, any line number is out of range, or a line number
/// is targeted more than once.
/// </exception>
internal static string ApplyReplaceLines(string content, IReadOnlyList<FileLineEdit> edits)
{
if (edits.Count == 0)
{
throw new ArgumentException("At least one line edit must be provided.");
}
List<string> lines = SplitLinesKeepEnds(content);
var seen = new HashSet<int>();
foreach (FileLineEdit edit in edits)
{
if (!seen.Add(edit.LineNumber))
{
throw new ArgumentException($"Duplicate line_number {edit.LineNumber} in edits.");
}
if (edit.LineNumber < 1 || edit.LineNumber > lines.Count)
{
throw new ArgumentException(
$"line_number {edit.LineNumber} is out of range (file has {lines.Count} lines).");
}
}
foreach (FileLineEdit edit in edits)
{
// An empty replacement removes the line (content and its line break); otherwise the
// replacement is written verbatim, so the caller controls any trailing newline.
lines[edit.LineNumber - 1] = edit.NewLine;
}
return string.Concat(lines);
}
private static int CountOccurrences(string content, string value)
{
int count = 0;
int index = 0;
while ((index = content.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += value.Length;
}
return count;
}
/// <summary>
/// Splits content into lines, keeping each line's trailing newline (<c>\r\n</c>, <c>\n</c>, or a lone
/// <c>\r</c>) attached. The final line has no terminator when the content does not end with a newline.
/// </summary>
private static List<string> SplitLinesKeepEnds(string content)
{
var lines = new List<string>();
int start = 0;
for (int i = 0; i < content.Length; i++)
{
char c = content[i];
if (c == '\n')
{
lines.Add(content.Substring(start, i - start + 1));
start = i + 1;
}
else if (c == '\r')
{
// Treat "\r\n" as a single terminator; a lone "\r" also terminates a line.
int end = (i + 1 < content.Length && content[i + 1] == '\n') ? i + 2 : i + 1;
lines.Add(content.Substring(start, end - start));
i = end - 1;
start = end;
}
}
if (start < content.Length)
{
lines.Add(content.Substring(start));
}
return lines;
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single whole-line replacement used by the file access and file memory
/// <c>replace_lines</c> tools.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileLineEdit
{
/// <summary>
/// Gets or sets the 1-based line number to replace.
/// </summary>
[JsonPropertyName("line_number")]
[Description("1-based line number to replace.")]
public int LineNumber { get; set; }
/// <summary>
/// Gets or sets the literal replacement text for the line, including any trailing newline to keep.
/// An empty string deletes the line entirely (its content and its line break).
/// </summary>
[JsonPropertyName("new_line")]
[Description("Literal replacement text for the line, including any trailing newline you want to keep (the editor does not add one). Set to an empty string to delete the line entirely, including its line break.")]
public string NewLine { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a match found within a file during a search operation.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileSearchMatch
{
/// <summary>
/// Gets or sets the 1-based line number where the match was found.
/// </summary>
[JsonPropertyName("lineNumber")]
public int LineNumber { get; set; }
/// <summary>
/// Gets or sets the content of the matching line.
/// </summary>
[JsonPropertyName("line")]
public string Line { get; set; } = string.Empty;
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a result from searching files, containing the file name, a content snippet, and matching lines.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileSearchResult
{
/// <summary>
/// Gets or sets the name of the file that matched the search.
/// </summary>
[JsonPropertyName("fileName")]
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a snippet of content from the file around the first match.
/// </summary>
[JsonPropertyName("snippet")]
public string Snippet { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the lines where matches were found.
/// </summary>
[JsonPropertyName("matchingLines")]
public List<FileSearchMatch> MatchingLines { get; set; } = [];
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single direct child of a directory in an <see cref="AgentFileStore"/>,
/// returned by <see cref="AgentFileStore.ListChildrenAsync"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileStoreEntry
{
/// <summary>The <see cref="Type"/> value for a regular file.</summary>
public const string File = "file";
/// <summary>The <see cref="Type"/> value for a subdirectory.</summary>
public const string Directory = "directory";
/// <summary>
/// Initializes a new instance of the <see cref="FileStoreEntry"/> class.
/// </summary>
public FileStoreEntry()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FileStoreEntry"/> class.
/// </summary>
/// <param name="name">The name of the entry (a single path segment, not a full path).</param>
/// <param name="type">Either <see cref="File"/> or <see cref="Directory"/>.</param>
public FileStoreEntry(string name, string type)
{
this.Name = name;
this.Type = type;
}
/// <summary>
/// Gets or sets the name of the entry (a single path segment relative to the listed directory).
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the entry type, either <see cref="File"/> or <see cref="Directory"/>.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = File;
}
@@ -0,0 +1,389 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A file-system-backed implementation of <see cref="AgentFileStore"/> that stores files on disk
/// under a configurable root directory.
/// </summary>
/// <remarks>
/// <para>
/// All paths passed to this store are resolved relative to the root directory provided
/// at construction time. Lexical path traversal attempts (for example, via <c>..</c> segments
/// or absolute paths) are rejected with an <see cref="ArgumentException"/>.
/// </para>
/// <para>
/// The root directory is created automatically if it does not already exist.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileSystemAgentFileStore : AgentFileStore
{
/// <summary>
/// The canonical full path of the root directory, always ending with a directory separator.
/// </summary>
private readonly string _rootPath;
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentFileStore"/> class.
/// </summary>
/// <param name="rootDirectory">
/// The root directory under which all files are stored. Created if it does not exist.
/// </param>
public FileSystemAgentFileStore(string rootDirectory)
{
_ = Throw.IfNullOrWhitespace(rootDirectory);
// Canonicalize the root and ensure it ends with a separator for prefix comparison.
string fullRoot = Path.GetFullPath(rootDirectory);
if (!fullRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) &&
!fullRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
fullRoot += Path.DirectorySeparatorChar;
}
this._rootPath = fullRoot;
Directory.CreateDirectory(fullRoot);
}
/// <inheritdoc />
public override async Task WriteAsync(string path, string content, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
// Ensure the parent directory exists.
string? parentDir = Path.GetDirectoryName(fullPath);
if (parentDir is not null)
{
Directory.CreateDirectory(parentDir);
}
#if NET8_0_OR_GREATER
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var writer = new StreamWriter(fullPath, false, Encoding.UTF8);
await writer.WriteAsync(content).ConfigureAwait(false);
#endif
}
/// <inheritdoc />
public override async Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
if (!File.Exists(fullPath))
{
return null;
}
#if NET8_0_OR_GREATER
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var reader = new StreamReader(fullPath, Encoding.UTF8);
return await reader.ReadToEndAsync().ConfigureAwait(false);
#endif
}
/// <inheritdoc />
public override Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
if (!File.Exists(fullPath))
{
return Task.FromResult(false);
}
File.Delete(fullPath);
return Task.FromResult(true);
}
/// <inheritdoc />
public override Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return Task.FromResult<IReadOnlyList<FileStoreEntry>>([]);
}
// Subdirectories first, then files. Skip symlinks/reparse points for both.
var entries = new List<FileStoreEntry>();
foreach (string dir in Directory.GetDirectories(fullDir))
{
if ((File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
string? name = Path.GetFileName(dir);
if (name is not null)
{
entries.Add(new FileStoreEntry(name, FileStoreEntry.Directory));
}
}
foreach (string file in Directory.GetFiles(fullDir))
{
if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
string? name = Path.GetFileName(file);
if (name is not null)
{
entries.Add(new FileStoreEntry(name, FileStoreEntry.File));
}
}
return Task.FromResult<IReadOnlyList<FileStoreEntry>>(entries);
}
/// <inheritdoc />
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
return Task.FromResult(File.Exists(fullPath));
}
/// <inheritdoc />
public override async Task<IReadOnlyList<FileSearchResult>> SearchAsync(
string directory,
string regexPattern,
string? globPattern = null,
bool recursive = false,
CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return [];
}
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null;
var results = new List<FileSearchResult>();
foreach (string filePath in EnumerateFiles(fullDir, recursive))
{
// The file path relative to the search directory, using forward slashes.
string relativeName = GetRelativeStorePath(fullDir, filePath);
// Apply the optional glob filter on the relative path.
if (!StorePaths.MatchesGlob(relativeName, matcher))
{
continue;
}
// Read file content.
#if NET8_0_OR_GREATER
string fileContent = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
string fileContent;
using (var reader = new StreamReader(filePath, Encoding.UTF8))
{
fileContent = await reader.ReadToEndAsync().ConfigureAwait(false);
}
#endif
// Search each line for regex matches, tracking line numbers and building a snippet.
string[] lines = fileContent.Split('\n');
var matchingLines = new List<FileSearchMatch>();
string? firstSnippet = null;
int lineStartOffset = 0;
for (int i = 0; i < lines.Length; i++)
{
Match match = regex.Match(lines[i]);
if (match.Success)
{
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
// Build a context snippet around the first match (±50 chars).
if (firstSnippet is null)
{
int charIndex = lineStartOffset + match.Index;
int snippetStart = Math.Max(0, charIndex - 50);
int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
}
}
// Advance the offset past this line (including the '\n' separator).
lineStartOffset += lines[i].Length + 1;
}
if (matchingLines.Count > 0)
{
results.Add(new FileSearchResult
{
FileName = relativeName,
Snippet = firstSnippet!,
MatchingLines = matchingLines,
});
}
}
return results;
}
/// <summary>
/// Enumerates the files directly under <paramref name="directory"/> (or all descendant files when
/// <paramref name="recursive"/> is <see langword="true"/>), skipping symlinks/reparse points for both
/// files and directories to prevent reading outside the root.
/// </summary>
private static IEnumerable<string> EnumerateFiles(string directory, bool recursive)
{
foreach (string filePath in Directory.EnumerateFiles(directory))
{
// Skip files that are symlinks/reparse points.
if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
yield return filePath;
}
if (!recursive)
{
yield break;
}
foreach (string subDir in Directory.EnumerateDirectories(directory))
{
// Skip symlinked/reparse-point directories so recursion cannot escape the root.
if ((File.GetAttributes(subDir) & FileAttributes.ReparsePoint) != 0)
{
continue;
}
foreach (string filePath in EnumerateFiles(subDir, recursive: true))
{
yield return filePath;
}
}
}
/// <summary>
/// Returns the path of <paramref name="filePath"/> relative to <paramref name="baseDirectory"/>,
/// normalized to forward-slash separators. Assumes <paramref name="filePath"/> resides under
/// <paramref name="baseDirectory"/> (as produced by <see cref="EnumerateFiles"/>).
/// </summary>
private static string GetRelativeStorePath(string baseDirectory, string filePath)
{
string baseTrimmed = baseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string relative = filePath.Substring(baseTrimmed.Length)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return relative.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
}
/// <inheritdoc />
public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafeDirectoryPath(path);
Directory.CreateDirectory(fullPath);
return Task.CompletedTask;
}
/// <summary>
/// Resolves a relative file path to a safe absolute path under the root directory.
/// Rejects paths that would escape the root via traversal, rooted paths, or symbolic links.
/// </summary>
private string ResolveSafePath(string relativePath)
{
// Normalize and validate the relative path (rejects rooted, traversal, etc.).
string normalized = StorePaths.NormalizeRelativePath(relativePath);
// Convert to OS-native separators before combining.
string nativePath = normalized.Replace('/', Path.DirectorySeparatorChar);
string combined = Path.Combine(this._rootPath, nativePath);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.StartsWith(this._rootPath, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{relativePath}'. The resolved path escapes the root directory.",
nameof(relativePath));
}
// Reject symlinks/reparse points in any path segment to prevent escaping the root.
ThrowIfContainsSymlink(fullPath, this._rootPath);
return fullPath;
}
/// <summary>
/// Checks each path segment between the trusted root and the resolved path for symbolic links
/// or reparse points. Throws <see cref="ArgumentException"/> if any segment is a symlink.
/// Stops checking at the first segment that does not exist on disk (for write scenarios).
/// Uses <see cref="File.GetAttributes(string)"/> directly so that dangling symlinks (whose targets
/// do not exist) are still detected via their <see cref="FileAttributes.ReparsePoint"/> flag.
/// </summary>
private static void ThrowIfContainsSymlink(string fullPath, string rootPath)
{
string rootTrimmed = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string relative = fullPath.Substring(rootTrimmed.Length);
string[] segments = relative.Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
string current = rootTrimmed;
foreach (string segment in segments)
{
current = Path.Combine(current, segment);
FileAttributes attributes;
try
{
attributes = File.GetAttributes(current);
}
catch (FileNotFoundException)
{
// Segment does not exist on disk (write scenario); stop checking.
break;
}
catch (DirectoryNotFoundException)
{
break;
}
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new ArgumentException(
"Invalid path: the resolved path contains a symbolic link or reparse point.");
}
}
}
/// <summary>
/// Resolves a relative directory path to a safe absolute path under the root directory.
/// An empty string resolves to the root directory itself.
/// </summary>
private string ResolveSafeDirectoryPath(string relativeDirectory)
{
if (string.IsNullOrEmpty(relativeDirectory))
{
return this._rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return this.ResolveSafePath(relativeDirectory);
}
}
@@ -0,0 +1,192 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// An in-memory implementation of <see cref="AgentFileStore"/> that stores files in a dictionary.
/// </summary>
/// <remarks>
/// This implementation is suitable for testing and lightweight scenarios where persistence is not required.
/// Directory concepts are simulated using path prefixes — no explicit directory structure is maintained.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class InMemoryAgentFileStore : AgentFileStore
{
private readonly ConcurrentDictionary<string, string> _files = new(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public override Task WriteAsync(string path, string content, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
this._files[path] = content;
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task<string?> ReadAsync(string path, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
this._files.TryGetValue(path, out string? content);
return Task.FromResult(content);
}
/// <inheritdoc />
public override Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
return Task.FromResult(this._files.TryRemove(path, out _));
}
/// <inheritdoc />
public override Task<IReadOnlyList<FileStoreEntry>> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
{
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
}
// A subdirectory is the first path segment of any key whose remainder (after the prefix)
// still contains a separator. Collect distinct first segments, preserving original casing.
var directories = new List<string>();
var seenDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var files = new List<string>();
foreach (string key in this._files.Keys)
{
if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string remainder = key.Substring(prefix.Length);
int separatorIndex = remainder.IndexOf("/", StringComparison.Ordinal);
if (separatorIndex < 0)
{
files.Add(remainder);
}
else if (separatorIndex > 0)
{
string segment = remainder.Substring(0, separatorIndex);
if (seenDirectories.Add(segment))
{
directories.Add(segment);
}
}
}
// Subdirectories first, then files.
FileStoreEntry[] entries =
[
.. directories.Select(d => new FileStoreEntry(d, FileStoreEntry.Directory)),
.. files.Select(f => new FileStoreEntry(f, FileStoreEntry.File)),
];
return Task.FromResult<IReadOnlyList<FileStoreEntry>>(entries);
}
/// <inheritdoc />
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
{
path = StorePaths.NormalizeRelativePath(path);
return Task.FromResult(this._files.ContainsKey(path));
}
/// <inheritdoc />
public override Task<IReadOnlyList<FileSearchResult>> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default)
{
// Normalize the directory prefix for path matching.
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
}
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null;
var results = new List<FileSearchResult>();
foreach (var kvp in this._files)
{
// Only consider files within the target directory (by path prefix).
if (!kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// The file path relative to the search directory.
string relativeName = kvp.Key.Substring(prefix.Length);
// When not recursive, exclude files in subdirectories (direct children only).
if (!recursive && relativeName.IndexOf("/", StringComparison.Ordinal) >= 0)
{
continue;
}
// Apply the optional glob filter on the relative path.
if (!StorePaths.MatchesGlob(relativeName, matcher))
{
continue;
}
// Search each line for regex matches, tracking line numbers and building a snippet.
string fileContent = kvp.Value;
string[] lines = fileContent.Split('\n');
var matchingLines = new List<FileSearchMatch>();
string? firstSnippet = null;
int lineStartOffset = 0;
for (int i = 0; i < lines.Length; i++)
{
Match match = regex.Match(lines[i]);
if (match.Success)
{
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
// Build a context snippet around the first match (±50 chars).
if (firstSnippet is null)
{
int charIndex = lineStartOffset + match.Index;
int snippetStart = Math.Max(0, charIndex - 50);
int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
}
}
// Advance the offset past this line (including the '\n' separator).
lineStartOffset += lines[i].Length + 1;
}
if (matchingLines.Count > 0)
{
results.Add(new FileSearchResult
{
FileName = relativeName,
Snippet = firstSnippet!,
MatchingLines = matchingLines,
});
}
}
return Task.FromResult<IReadOnlyList<FileSearchResult>>(results);
}
/// <inheritdoc />
public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
// No-op: directories are implicit from file paths in the in-memory store.
return Task.CompletedTask;
}
}
@@ -0,0 +1,119 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.FileSystemGlobbing;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal helper for normalizing and validating relative store paths and matching glob patterns.
/// Shared across <see cref="AgentFileStore"/> implementations and <see cref="FileMemoryProvider"/>.
/// </summary>
internal static class StorePaths
{
/// <summary>
/// Normalizes a relative path by replacing backslashes with forward slashes, trimming leading
/// and trailing separators, and collapsing consecutive separators. Also validates that the path
/// does not contain rooted paths, drive roots, or <c>.</c>/<c>..</c> traversal segments.
/// </summary>
/// <param name="path">The relative path to normalize.</param>
/// <param name="isDirectory">
/// When <see langword="true"/>, the path represents a directory and an empty result (meaning root) is allowed.
/// When <see langword="false"/> (default), the path represents a file and an empty result is rejected.
/// </param>
/// <returns>The normalized forward-slash path.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="path"/> is rooted, starts with a drive letter, contains
/// <c>.</c> or <c>..</c> segments, or is empty when <paramref name="isDirectory"/> is <see langword="false"/>.
/// </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) ||
path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
nameof(path));
}
// Split, validate segments, and filter out empty segments to collapse consecutive separators.
string[] segments = normalized.Split('/');
var cleanSegments = new List<string>(segments.Length);
foreach (string segment in segments)
{
if (segment.Length == 0)
{
continue;
}
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
nameof(path));
}
cleanSegments.Add(segment);
}
string result = string.Join("/", cleanSegments);
if (!isDirectory && result.Length == 0)
{
throw new ArgumentException("A file path must not be empty.", nameof(path));
}
return result;
}
/// <summary>
/// Creates a <see cref="Matcher"/> for the specified glob pattern. Use the returned instance
/// to test multiple file names without allocating a new matcher for each one.
/// </summary>
/// <param name="filePattern">
/// The glob pattern to match against (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// </param>
/// <returns>A <see cref="Matcher"/> configured with the specified pattern.</returns>
internal static Matcher CreateGlobMatcher(string filePattern)
{
var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
matcher.AddInclude(filePattern);
return matcher;
}
/// <summary>
/// Determines whether a file name matches a pre-built glob <see cref="Matcher"/>.
/// </summary>
/// <param name="fileName">The file name to test (not a full path — just the name).</param>
/// <param name="matcher">
/// A pre-built <see cref="Matcher"/> to test against.
/// When <see langword="null"/>, this method returns <see langword="true"/> for any file name.
/// </param>
/// <returns><see langword="true"/> if the file name matches the pattern or if the matcher is <see langword="null"/>; otherwise, <see langword="false"/>.</returns>
internal static bool MatchesGlob(string fileName, Matcher? matcher)
{
if (matcher is null)
{
return true;
}
PatternMatchingResult result = matcher.Match(fileName);
return result.HasMatches;
}
}
@@ -0,0 +1,217 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="LoopEvaluator"/> that uses a separate judge chat client to decide whether the user's original request
/// has been fully addressed, continuing the loop (with the judge's gap analysis as feedback) while the answer is "no".
/// </summary>
/// <remarks>
/// <para>
/// After each iteration the judge is queried directly (without any agent tools, session, or middleware) with the
/// original request and the agent's latest response, and asked for a structured <see cref="JudgeVerdict"/>. If the
/// judge client does not honor structured output, the verdict falls back to parsing the raw text for the
/// non-overlapping <see cref="DoneVerdictMarker"/> / <see cref="MoreVerdictMarker"/> markers (with
/// <see cref="MoreVerdictMarker"/> winning, so the loop keeps running, when the verdict is ambiguous or absent).
/// </para>
/// <para>
/// When the request is not yet answered, the evaluator returns feedback built from
/// <see cref="AIJudgeLoopEvaluatorOptions.FeedbackMessageTemplate"/> with the judge's gap analysis substituted for
/// <see cref="GapAnalysisPlaceholder"/>. How that feedback is delivered to the agent (and whether the session is
/// reset) is decided by the <see cref="LoopAgent"/> that consumes this evaluator.
/// </para>
/// <para>
/// The judge instructions act as a template: any occurrence of <see cref="CriteriaPlaceholder"/> is replaced with the
/// rendered <see cref="AIJudgeLoopEvaluatorOptions.Criteria"/> (or removed when no criteria are supplied), letting
/// callers add bespoke standards the response must satisfy.
/// </para>
/// <para>
/// LLM-judged loops are costly and probabilistic, so consider setting a stricter
/// <see cref="LoopAgentOptions.MaxIterations"/> on the owning <see cref="LoopAgent"/>.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> Using this evaluator is an explicit opt-in — the caller
/// must construct an <see cref="AIJudgeLoopEvaluator"/> with a judge <see cref="IChatClient"/> and add
/// it to the <see cref="LoopAgent"/>'s evaluator set; no judge is used by default. The judge
/// introduces a second external LLM boundary in addition to the agent's own model: on every iteration
/// it is sent the original request and the agent's latest response, both of which may contain
/// sensitive or untrusted content. A compromised or malicious judge endpoint could exfiltrate that
/// data, or return a manipulated <see cref="JudgeVerdict"/>/gap analysis that is fed back into the loop
/// as feedback, potentially steering the agent via indirect prompt injection. Only configure a judge
/// <see cref="IChatClient"/> that points at a service you trust as much as the primary model.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AIJudgeLoopEvaluator : LoopEvaluator
{
/// <summary>The default system instructions used to prompt the judge.</summary>
/// <remarks>
/// Acts as a template: the trailing <see cref="CriteriaPlaceholder"/> is replaced with the rendered
/// <see cref="AIJudgeLoopEvaluatorOptions.Criteria"/> (or removed when none are supplied).
/// </remarks>
public const string DefaultInstructions =
"You are an evaluator. You are given a user's original request and an agent's latest response. " +
"Decide whether the agent has fully addressed the original request. " +
"Set 'answered' to true if the request has been fully addressed, or false if more work is still required. " +
"When 'answered' is false, use 'gapAnalysis' to explain what is still missing or what work remains. " +
"If you cannot return structured output, reply with " + DoneVerdictMarker + " when the request has been fully " +
"addressed, or " + MoreVerdictMarker + " when more work is still required." +
CriteriaPlaceholder;
/// <summary>
/// The verdict marker the judge is asked to emit (for clients that do not honor structured output) when the
/// original request has been fully addressed.
/// </summary>
/// <remarks>
/// <see cref="DoneVerdictMarker"/> and <see cref="MoreVerdictMarker"/> are deliberately non-overlapping (neither is
/// a substring of the other), so the text fallback cannot misclassify one verdict as the other. When the marker is
/// ambiguous or absent, <see cref="MoreVerdictMarker"/> wins so the loop keeps running rather than stopping on an
/// incomplete answer.
/// </remarks>
public const string DoneVerdictMarker = "VERDICT: DONE";
/// <summary>
/// The verdict marker the judge is asked to emit (for clients that do not honor structured output) when more work
/// is still required. Takes precedence over <see cref="DoneVerdictMarker"/> when both (or neither) are present.
/// </summary>
public const string MoreVerdictMarker = "VERDICT: MORE";
/// <summary>
/// The placeholder token within <see cref="DefaultInstructions"/> (or a custom
/// <see cref="AIJudgeLoopEvaluatorOptions.Instructions"/>) that is replaced with the rendered
/// <see cref="AIJudgeLoopEvaluatorOptions.Criteria"/>. When no criteria are supplied, the placeholder is removed.
/// </summary>
public const string CriteriaPlaceholder = "{criteria}";
/// <summary>
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
/// <see cref="AIJudgeLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced with the judge's gap analysis.
/// </summary>
public const string GapAnalysisPlaceholder = "{gap_analysis}";
/// <summary>The default template used to build the feedback produced when the request is not yet answered.</summary>
public const string DefaultFeedbackMessageTemplate =
"Your previous response did not fully address the original request. " +
"The following is still missing or incomplete: " + GapAnalysisPlaceholder + " " +
"Please continue and fully address the original request.";
/// <summary>The value substituted for the gap analysis when the judge did not provide one.</summary>
private const string UnknownGapAnalysis = "<unknown>";
private readonly IChatClient _judgeClient;
private readonly string _instructions;
private readonly string _feedbackMessageTemplate;
/// <summary>
/// Initializes a new instance of the <see cref="AIJudgeLoopEvaluator"/> class.
/// </summary>
/// <param name="judgeClient">
/// The chat client used to judge whether the original request was answered. <strong>Security:</strong>
/// This client is sent the original request and the agent's latest response on every iteration, so
/// only point it at a service you trust as much as the primary model — see the type-level security
/// considerations for the exfiltration and prompt-injection risks of an untrusted judge.
/// </param>
/// <param name="options">Optional configuration for the judge. When <see langword="null"/>, defaults are used.</param>
/// <exception cref="ArgumentNullException"><paramref name="judgeClient"/> is <see langword="null"/>.</exception>
public AIJudgeLoopEvaluator(IChatClient judgeClient, AIJudgeLoopEvaluatorOptions? options = null)
{
this._judgeClient = Throw.IfNull(judgeClient);
this._instructions = (options?.Instructions ?? DefaultInstructions)
.Replace(CriteriaPlaceholder, RenderCriteria(options?.Criteria));
this._feedbackMessageTemplate = options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate;
}
/// <inheritdoc />
public override async ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
// Build the judge's user message from AIContent so non-text request content (images, data, etc.) is
// preserved rather than flattened to text. The original request's contents are framed between header
// text segments, followed by the agent's latest response text.
var userContents = new List<AIContent>
{
new TextContent("# Has the original request been fully addressed?\n\n## Original request:\n"),
};
foreach (ChatMessage message in context.InitialMessages)
{
userContents.AddRange(message.Contents);
}
userContents.Add(new TextContent($"\n\n## Agent's latest response:\n{context.LastResponse.Text}"));
List<ChatMessage> judgeMessages =
[
new ChatMessage(ChatRole.System, this._instructions),
new ChatMessage(ChatRole.User, userContents),
];
bool answered;
string gapAnalysis = UnknownGapAnalysis;
ChatResponse<JudgeVerdict> response = await this._judgeClient
.GetResponseAsync<JudgeVerdict>(judgeMessages, LoopJsonContext.Default.Options, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (response.TryGetResult(out JudgeVerdict? verdict) && verdict is not null)
{
answered = verdict.Answered;
if (!string.IsNullOrWhiteSpace(verdict.GapAnalysis))
{
gapAnalysis = verdict.GapAnalysis;
}
}
else
{
// Fallback for clients that do not honor structured output: look for the explicit, non-overlapping verdict
// markers. MoreVerdictMarker wins so an ambiguous or marker-less reply keeps looping rather than stopping
// on an incomplete answer.
string text = response.Text.ToUpperInvariant();
answered = !text.Contains(MoreVerdictMarker) && text.Contains(DoneVerdictMarker);
}
// The request is answered: stop looping.
if (answered)
{
return LoopEvaluation.Stop();
}
// Not yet answered: continue, providing feedback describing what is still missing.
string feedback = this._feedbackMessageTemplate.Replace(GapAnalysisPlaceholder, gapAnalysis);
return LoopEvaluation.Continue(feedback);
}
/// <summary>
/// Renders the supplied <paramref name="criteria"/> into a bullet block appended at <see cref="CriteriaPlaceholder"/>,
/// or an empty string when no non-blank criteria are supplied.
/// </summary>
private static string RenderCriteria(IEnumerable<string>? criteria)
{
if (criteria is null)
{
return string.Empty;
}
var builder = new StringBuilder();
foreach (string criterion in criteria)
{
if (!string.IsNullOrWhiteSpace(criterion))
{
builder.Append("\n- ").Append(criterion);
}
}
return builder.Length == 0
? string.Empty
: "\n\nThe response must satisfy all of the following criteria:" + builder;
}
}
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides configuration options for <see cref="AIJudgeLoopEvaluator"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AIJudgeLoopEvaluatorOptions
{
/// <summary>
/// Gets or sets the system instructions used to prompt the judge, or <see langword="null"/> to use
/// <see cref="AIJudgeLoopEvaluator.DefaultInstructions"/>.
/// </summary>
/// <remarks>
/// Any occurrence of <see cref="AIJudgeLoopEvaluator.CriteriaPlaceholder"/> in the instructions is replaced with
/// the rendered <see cref="Criteria"/> (or removed when no criteria are supplied). Instructions that omit the
/// placeholder do not receive the criteria.
/// </remarks>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets an optional list of additional criteria the agent's response must satisfy, evaluated by the judge
/// alongside the original request.
/// </summary>
/// <remarks>
/// When supplied, the criteria are rendered into the judge instructions wherever
/// <see cref="AIJudgeLoopEvaluator.CriteriaPlaceholder"/> appears (including in
/// <see cref="AIJudgeLoopEvaluator.DefaultInstructions"/>). When <see langword="null"/> or empty, the placeholder is
/// removed and no criteria are added.
/// </remarks>
public IEnumerable<string>? Criteria { get; set; }
/// <summary>
/// Gets or sets the template used to build the feedback produced when the judge decides the original request was
/// not fully addressed, or <see langword="null"/> to use
/// <see cref="AIJudgeLoopEvaluator.DefaultFeedbackMessageTemplate"/>.
/// </summary>
/// <remarks>
/// Any occurrence of <see cref="AIJudgeLoopEvaluator.GapAnalysisPlaceholder"/> in the template is replaced with the
/// judge's gap analysis (or a placeholder when none is available).
/// </remarks>
public string? FeedbackMessageTemplate { get; set; }
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="LoopEvaluator"/> that keeps re-invoking the wrapped agent until a <see cref="BackgroundAgentsProvider"/>
/// reports that no background tasks are still running.
/// </summary>
/// <remarks>
/// <para>
/// The required <see cref="BackgroundAgentsProvider"/> is not supplied directly. It is resolved at evaluation time from
/// the looped agent via <see cref="AIAgent.GetService{TService}(object?)"/>. This works because an agent surfaces its
/// registered <see cref="AIContextProvider"/> instances through <c>GetService</c>, so a single
/// <see cref="BackgroundAgentsProvider"/> attached to the agent's session is discovered automatically.
/// </para>
/// <para>
/// Only tasks that are still running are treated as incomplete; completed, failed, and lost tasks are terminal and do
/// not keep the loop going. While running tasks remain, the evaluator continues with feedback built from a template (see
/// <see cref="BackgroundTaskCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>), with the running task list
/// substituted for <see cref="IncompleteTasksPlaceholder"/> and the running task count substituted for
/// <see cref="IncompleteTaskCountPlaceholder"/>. How that feedback is delivered to the agent (and whether the session
/// is reset) is decided by the <see cref="LoopAgent"/> that consumes this evaluator.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundTaskCompletionLoopEvaluator : LoopEvaluator
{
/// <summary>
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
/// <see cref="BackgroundTaskCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced, on each
/// evaluation, with a formatted list of the background tasks that are still running.
/// </summary>
public const string IncompleteTasksPlaceholder = "{incomplete_tasks}";
/// <summary>
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
/// <see cref="BackgroundTaskCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced, on each
/// evaluation, with the number of background tasks that are still running.
/// </summary>
public const string IncompleteTaskCountPlaceholder = "{incomplete_task_count}";
/// <summary>The default template used to build the feedback produced while background tasks are still running.</summary>
public const string DefaultFeedbackMessageTemplate =
"You still have " + IncompleteTaskCountPlaceholder + " background task(s) running that must finish before you " +
"can complete the work:\n" + IncompleteTasksPlaceholder + "\n\n" +
"Wait for these tasks to complete, retrieve their results, and incorporate them. Only stop once every " +
"background task has finished.";
private readonly string _feedbackMessageTemplate;
/// <summary>
/// Initializes a new instance of the <see cref="BackgroundTaskCompletionLoopEvaluator"/> class.
/// </summary>
/// <param name="options">
/// Optional configuration for the evaluator, including the feedback message template. When <see langword="null"/>,
/// defaults are used.
/// </param>
public BackgroundTaskCompletionLoopEvaluator(BackgroundTaskCompletionLoopEvaluatorOptions? options = null)
{
this._feedbackMessageTemplate = options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate;
}
/// <inheritdoc />
public override ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
BackgroundAgentsProvider provider = context.Agent.GetService<BackgroundAgentsProvider>()
?? throw new InvalidOperationException(
$"{nameof(BackgroundTaskCompletionLoopEvaluator)} requires a {nameof(BackgroundAgentsProvider)} to be registered on the agent, but none could be resolved via GetService.");
IReadOnlyList<BackgroundTaskInfo> incomplete = provider.GetIncompleteTasks(context.Session);
if (incomplete.Count == 0)
{
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
}
string feedback = this._feedbackMessageTemplate
.Replace(IncompleteTaskCountPlaceholder, incomplete.Count.ToString(CultureInfo.InvariantCulture))
.Replace(IncompleteTasksPlaceholder, FormatIncompleteTasks(incomplete));
return new ValueTask<LoopEvaluation>(LoopEvaluation.Continue(feedback));
}
private static string FormatIncompleteTasks(IReadOnlyList<BackgroundTaskInfo> incomplete)
{
var sb = new StringBuilder();
for (int i = 0; i < incomplete.Count; i++)
{
BackgroundTaskInfo task = incomplete[i];
sb.Append("- #").Append(task.Id).Append(" (").Append(task.AgentName).Append("): ").Append(task.Description);
if (i < incomplete.Count - 1)
{
sb.Append('\n');
}
}
return sb.ToString();
}
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides configuration options for <see cref="BackgroundTaskCompletionLoopEvaluator"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class BackgroundTaskCompletionLoopEvaluatorOptions
{
/// <summary>
/// Gets or sets the template used to build the feedback produced while background tasks are still running,
/// or <see langword="null"/> to use <see cref="BackgroundTaskCompletionLoopEvaluator.DefaultFeedbackMessageTemplate"/>.
/// </summary>
/// <remarks>
/// Any occurrence of <see cref="BackgroundTaskCompletionLoopEvaluator.IncompleteTasksPlaceholder"/> in the template
/// is replaced, on each evaluation, with a formatted list of the background tasks that are still running, and any
/// occurrence of <see cref="BackgroundTaskCompletionLoopEvaluator.IncompleteTaskCountPlaceholder"/> is replaced with
/// the number of those tasks. When a placeholder is absent the corresponding value is not rendered.
/// </remarks>
public string? FeedbackMessageTemplate { get; set; }
}
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="LoopEvaluator"/> that stops the loop once a configured marker string appears in the agent's latest
/// response, and otherwise continues with feedback asking the agent to keep working and to emit the marker when done.
/// </summary>
/// <remarks>
/// The feedback produced while the marker is absent is built from a template (see
/// <see cref="CompletionMarkerLoopEvaluatorOptions.FeedbackMessageTemplate"/>) with the configured marker substituted
/// for <see cref="CompletionMarkerPlaceholder"/>, and the agent's latest response text substituted for
/// <see cref="LastResponsePlaceholder"/>. How that feedback is delivered to the agent (and whether the session
/// is reset) is decided by the <see cref="LoopAgent"/> that consumes this evaluator.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompletionMarkerLoopEvaluator : LoopEvaluator
{
/// <summary>
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
/// <see cref="CompletionMarkerLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced with the
/// configured completion marker.
/// </summary>
public const string CompletionMarkerPlaceholder = "{completion_marker}";
/// <summary>
/// The placeholder token within a custom <see cref="CompletionMarkerLoopEvaluatorOptions.FeedbackMessageTemplate"/>
/// that is replaced with the text of the agent's latest response. This is substituted on each evaluation, so it lets
/// the feedback echo back what the agent previously produced — useful when the consuming
/// <see cref="LoopAgent"/> uses <see cref="LoopAgentOptions.FreshContextPerIteration"/>, where the agent would
/// otherwise have no record of its prior output.
/// </summary>
public const string LastResponsePlaceholder = "{last_response}";
/// <summary>The default template used to build the feedback produced while the completion marker is absent.</summary>
public const string DefaultFeedbackMessageTemplate =
"Continue working on the request. When you have fully completed the task, end your response with the marker '" +
CompletionMarkerPlaceholder + "' to indicate completion.";
private readonly string _completionMarker;
private readonly string _feedbackMessageTemplate;
/// <summary>
/// Initializes a new instance of the <see cref="CompletionMarkerLoopEvaluator"/> class.
/// </summary>
/// <param name="completionMarker">The marker string that stops the loop once it appears in the agent's latest response text.</param>
/// <param name="options">Optional configuration for the feedback message. When <see langword="null"/>, defaults are used.</param>
/// <exception cref="System.ArgumentException"><paramref name="completionMarker"/> is <see langword="null"/>, empty, or whitespace.</exception>
public CompletionMarkerLoopEvaluator(string completionMarker, CompletionMarkerLoopEvaluatorOptions? options = null)
{
this._completionMarker = Throw.IfNullOrWhitespace(completionMarker);
// The completion marker is fixed, so substitute it once here. The optional {last_response} placeholder depends
// on the per-iteration response text, so it is substituted later in EvaluateAsync.
this._feedbackMessageTemplate = (options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate)
.Replace(CompletionMarkerPlaceholder, this._completionMarker);
}
/// <inheritdoc />
public override ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
if (context.LastResponse.Text.Contains(this._completionMarker))
{
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
}
string feedback = this._feedbackMessageTemplate.Replace(LastResponsePlaceholder, context.LastResponse.Text);
return new ValueTask<LoopEvaluation>(LoopEvaluation.Continue(feedback));
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides configuration options for <see cref="CompletionMarkerLoopEvaluator"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompletionMarkerLoopEvaluatorOptions
{
/// <summary>
/// Gets or sets the template used to build the feedback produced when the completion marker has not yet appeared,
/// or <see langword="null"/> to use <see cref="CompletionMarkerLoopEvaluator.DefaultFeedbackMessageTemplate"/>.
/// </summary>
/// <remarks>
/// Any occurrence of <see cref="CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder"/> in the template is
/// replaced with the configured completion marker. Any occurrence of
/// <see cref="CompletionMarkerLoopEvaluator.LastResponsePlaceholder"/> is replaced, on each evaluation, with the
/// text of the agent's latest response — useful for echoing the agent's prior output back to it when the consuming
/// <see cref="CompletionMarkerLoopEvaluator"/> is used with a fresh context per iteration.
/// </remarks>
public string? FeedbackMessageTemplate { get; set; }
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="LoopEvaluator"/> that delegates the re-invocation decision and feedback to a user-supplied callback.
/// </summary>
/// <remarks>
/// This is the most flexible evaluator: the supplied delegate receives the full <see cref="LoopContext"/> and returns
/// a <see cref="LoopEvaluation"/>, so it can decide both whether to continue and what feedback (if any) to provide.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class DelegateLoopEvaluator : LoopEvaluator
{
private readonly Func<LoopContext, CancellationToken, ValueTask<LoopEvaluation>> _evaluate;
/// <summary>
/// Initializes a new instance of the <see cref="DelegateLoopEvaluator"/> class.
/// </summary>
/// <param name="evaluate">A callback that decides whether to re-invoke the agent and what feedback to provide.</param>
/// <exception cref="ArgumentNullException"><paramref name="evaluate"/> is <see langword="null"/>.</exception>
public DelegateLoopEvaluator(Func<LoopContext, CancellationToken, ValueTask<LoopEvaluation>> evaluate)
{
this._evaluate = Throw.IfNull(evaluate);
}
/// <inheritdoc />
public override ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
return this._evaluate(context, cancellationToken);
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the structured verdict returned by the judge chat client used by <see cref="AIJudgeLoopEvaluator"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class JudgeVerdict
{
/// <summary>
/// Gets or sets a value indicating whether the agent has fully addressed the user's original request.
/// </summary>
[Description("True if the agent has fully addressed the original request, otherwise false.")]
public bool Answered { get; set; }
/// <summary>
/// Gets or sets an explanation of what is still missing when the request has not been fully addressed.
/// </summary>
[Description("When 'answered' is false, explain what is still missing or what work remains to fully address the original request.")]
public string GapAnalysis { get; set; } = string.Empty;
}
@@ -0,0 +1,548 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="DelegatingAIAgent"/> that re-invokes the wrapped agent in a loop until the configured
/// <see cref="LoopEvaluator"/> set decides to stop.
/// </summary>
/// <remarks>
/// <para>
/// After each run of the wrapped agent, the configured evaluators are asked whether to re-invoke the agent and what
/// feedback to carry forward. This enables patterns such as iterative refinement, working through a task list, or
/// judging whether the original request was answered. Out-of-the-box evaluators include
/// <see cref="AIJudgeLoopEvaluator"/>, <see cref="CompletionMarkerLoopEvaluator"/>, and
/// <see cref="DelegateLoopEvaluator"/>.
/// </para>
/// <para>
/// When multiple evaluators are supplied they are evaluated in order after each iteration. The first evaluator that
/// asks to re-invoke wins: its feedback drives the next iteration and the remaining evaluators are not evaluated. The
/// loop stops only when every evaluator asks to stop. Consequently, evaluator order is priority order and
/// <see cref="LoopEvaluation.Stop"/> means "this evaluator does not request continuation" rather than a veto that
/// terminates the loop; place stop-only guards accordingly.
/// </para>
/// <para>
/// The caller's initial messages are sent to the wrapped agent exactly once. By default (when
/// <see cref="LoopAgentOptions.FreshContextPerIteration"/> is <see langword="false"/>) the loop reuses a single session
/// and sends only the winning evaluator's feedback as the next input, letting the agent continue from session history.
/// When <see cref="LoopAgentOptions.FreshContextPerIteration"/> is <see langword="true"/>, each re-invocation restarts
/// from the original input messages plus an aggregated feedback log, and the session is reset for each iteration: a
/// loop-owned session is created anew, while a caller-supplied session is restored from a snapshot taken at the start
/// of the run (so the wrapped agent must support session serialization). An evaluator may instead supply the exact next
/// messages via <see cref="LoopEvaluation.ContinueWithMessages"/>, bypassing this construction.
/// </para>
/// <para>
/// The loop is bounded by a global safety cap (<see cref="LoopAgentOptions.MaxIterations"/>) regardless of the
/// evaluators. If an iteration produces a pending tool-approval request, the loop stops and returns that response to
/// the caller rather than attempting to resolve the approval automatically.
/// </para>
/// <para>
/// A non-streaming run returns, by default, a single <see cref="AgentResponse"/> that aggregates the full transcript
/// in order: the on-behalf-of messages the loop injected for each re-invocation followed by that iteration's response
/// messages. The caller's original input messages are not echoed. Set
/// <see cref="LoopAgentOptions.NonStreamingReturnsLastResponseOnly"/> to instead return only the final iteration's
/// response. A streaming run always yields every iteration's updates, emitting the injected on-behalf-of messages as
/// updates before each re-invocation. The injected messages can be attributed with
/// <see cref="LoopAgentOptions.OnBehalfOfAuthorName"/>, or omitted from the surfaced output entirely with
/// <see cref="LoopAgentOptions.ExcludeOnBehalfOfMessages"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class LoopAgent : DelegatingAIAgent
{
/// <summary>The default value used for <see cref="LoopAgentOptions.MaxIterations"/> when none is specified.</summary>
public const int DefaultMaxIterations = 10;
private readonly IReadOnlyList<LoopEvaluator> _evaluators;
private readonly int _maxIterations;
private readonly bool _freshContextPerIteration;
private readonly string? _onBehalfOfAuthorName;
private readonly bool _excludeOnBehalfOfMessages;
private readonly bool _nonStreamingReturnsLastResponseOnly;
private readonly System.Func<AgentSession, CancellationToken, ValueTask>? _sessionCreatedCallback;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LoopAgent"/> class with a single evaluator.
/// </summary>
/// <param name="innerAgent">The underlying agent to invoke in a loop.</param>
/// <param name="evaluator">The <see cref="LoopEvaluator"/> that decides whether to re-invoke the agent.</param>
/// <param name="options">Optional configuration for the loop. When <see langword="null"/>, defaults are used.</param>
/// <param name="loggerFactory">Optional factory used to create the loop's logger.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="innerAgent"/> or <paramref name="evaluator"/> is <see langword="null"/>.</exception>
/// <exception cref="System.ArgumentOutOfRangeException"><see cref="LoopAgentOptions.MaxIterations"/> is less than 1.</exception>
public LoopAgent(AIAgent innerAgent, LoopEvaluator evaluator, LoopAgentOptions? options = null, ILoggerFactory? loggerFactory = null)
: this(innerAgent, [Throw.IfNull(evaluator)], options, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LoopAgent"/> class with one or more evaluators.
/// </summary>
/// <param name="innerAgent">The underlying agent to invoke in a loop.</param>
/// <param name="evaluators">
/// The ordered collection of <see cref="LoopEvaluator"/> that decide whether to re-invoke the agent. They are evaluated in
/// order after each iteration and the first that asks to re-invoke wins.
/// </param>
/// <param name="options">Optional configuration for the loop. When <see langword="null"/>, defaults are used.</param>
/// <param name="loggerFactory">Optional factory used to create the loop's logger.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="innerAgent"/> or <paramref name="evaluators"/> is <see langword="null"/>, or <paramref name="evaluators"/> contains a <see langword="null"/> element.</exception>
/// <exception cref="System.ArgumentException"><paramref name="evaluators"/> is empty.</exception>
/// <exception cref="System.ArgumentOutOfRangeException"><see cref="LoopAgentOptions.MaxIterations"/> is less than 1.</exception>
public LoopAgent(AIAgent innerAgent, IEnumerable<LoopEvaluator> evaluators, LoopAgentOptions? options = null, ILoggerFactory? loggerFactory = null)
: base(innerAgent)
{
_ = Throw.IfNull(evaluators);
LoopEvaluator[] evaluatorArray = evaluators.ToArray();
if (evaluatorArray.Length == 0)
{
throw new System.ArgumentException("At least one evaluator must be supplied.", nameof(evaluators));
}
foreach (LoopEvaluator item in evaluatorArray)
{
_ = Throw.IfNull(item, nameof(evaluators));
}
this._evaluators = evaluatorArray;
this._maxIterations = Throw.IfLessThan(options?.MaxIterations ?? DefaultMaxIterations, 1);
this._freshContextPerIteration = options?.FreshContextPerIteration ?? false;
this._onBehalfOfAuthorName = options?.OnBehalfOfAuthorName;
this._excludeOnBehalfOfMessages = options?.ExcludeOnBehalfOfMessages ?? false;
this._nonStreamingReturnsLastResponseOnly = options?.NonStreamingReturnsLastResponseOnly ?? false;
this._sessionCreatedCallback = options?.SessionCreatedCallback;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<LoopAgent>();
}
/// <inheritdoc />
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
// Capture the caller's initial messages (sent once) and ensure the loop always runs against a session.
IReadOnlyList<ChatMessage> initialMessages = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
bool sessionProvidedByCaller = session is not null;
if (session is null)
{
session = await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false);
}
// When a fresh context is requested over a caller-supplied session, snapshot the pristine session up front so
// each re-invocation can restart from a fresh clone (see CreateFreshIterationSessionAsync). Taken before the
// first iteration mutates the session.
JsonElement? initialSessionSnapshot = this._freshContextPerIteration && sessionProvidedByCaller
? await this.InnerAgent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false)
: null;
LoopContext? context = null;
List<string?> feedbackLog = [];
IEnumerable<ChatMessage> currentMessages = initialMessages;
int iteration = 0;
// Aggregates the full transcript across iterations: each iteration's surfaced on-behalf-of input messages
// followed by that iteration's response messages. Unused when only the final response is returned.
List<ChatMessage> transcript = [];
// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
IReadOnlyList<ChatMessage> currentSurfaced = [];
while (true)
{
// Run the wrapped agent using the context's session once it exists (it may have been replaced for a fresh
// context), otherwise the resolved session for the first run.
AgentSession activeSession = context?.Session ?? session;
AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false);
iteration++;
// Record this iteration's on-behalf-of input (before the response it elicited) and the response itself.
transcript.AddRange(currentSurfaced);
transcript.AddRange(response.Messages);
// Create the context after the first run (so LastResponse is never null) and reuse it thereafter.
// Expose the feedback log as a read-only wrapper so evaluators cannot downcast and mutate it; the
// wrapper still reflects entries appended by the loop.
context ??= new LoopContext(this.InnerAgent, session, initialMessages, response, options) { Feedback = feedbackLog.AsReadOnly() };
context.Iteration = iteration;
context.LastResponse = response;
// Stop and surface the response when the agent is waiting for a tool approval.
if (HasPendingApprovalRequests(response))
{
return this.BuildResult(response, transcript);
}
// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
this.LogMaxIterationsReached(iteration);
return this.BuildResult(response, transcript);
}
// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
if (!step.ShouldContinue)
{
return this.BuildResult(response, transcript);
}
currentMessages = step.Messages;
currentSurfaced = step.SurfacedMessages;
}
}
/// <inheritdoc />
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
// Capture the caller's initial messages (sent once) and ensure the loop always runs against a session.
IReadOnlyList<ChatMessage> initialMessages = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
bool sessionProvidedByCaller = session is not null;
if (session is null)
{
session = await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false);
}
// When a fresh context is requested over a caller-supplied session, snapshot the pristine session up front so
// each re-invocation can restart from a fresh clone (see CreateFreshIterationSessionAsync). Taken before the
// first iteration mutates the session.
JsonElement? initialSessionSnapshot = this._freshContextPerIteration && sessionProvidedByCaller
? await this.InnerAgent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false)
: null;
LoopContext? context = null;
List<string?> feedbackLog = [];
IEnumerable<ChatMessage> currentMessages = initialMessages;
int iteration = 0;
// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
IReadOnlyList<ChatMessage> currentSurfaced = [];
while (true)
{
// Stream this iteration's updates to the caller while collecting them so the iteration's full
// response can be aggregated for evaluation (true per-iteration streaming). Uses the context's
// session once it exists (it may have been replaced for a fresh context), otherwise the resolved session.
AgentSession activeSession = context?.Session ?? session;
List<AgentResponseUpdate> updates = [];
// The on-behalf-of messages that drive this iteration are surfaced before the response they elicit (none
// for the first iteration). They are flushed lazily on the first inner update so they can be stamped with
// that update's ResponseId/AgentId, keeping them grouped with the iteration for downstream mergers.
bool surfacedPending = currentSurfaced.Count > 0;
await foreach (var update in this.InnerAgent.RunStreamingAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false))
{
if (surfacedPending)
{
foreach (ChatMessage surfaced in currentSurfaced)
{
yield return CreateOnBehalfOfUpdate(surfaced, update.ResponseId);
}
surfacedPending = false;
}
updates.Add(update);
yield return update;
}
// The inner agent produced no updates this iteration; surface the on-behalf-of messages anyway. Since there
// is no iteration response to inherit from, generate a ResponseId so they still group together downstream.
if (surfacedPending)
{
string fallbackResponseId = System.Guid.NewGuid().ToString("N");
foreach (ChatMessage surfaced in currentSurfaced)
{
yield return CreateOnBehalfOfUpdate(surfaced, fallbackResponseId);
}
}
// Aggregate this iteration's updates and record the result on the context.
iteration++;
AgentResponse response = updates.ToAgentResponse();
// Create the context after the first run (so LastResponse is never null) and reuse it thereafter.
// Expose the feedback log as a read-only wrapper so evaluators cannot downcast and mutate it; the
// wrapper still reflects entries appended by the loop.
context ??= new LoopContext(this.InnerAgent, session, initialMessages, response, options) { Feedback = feedbackLog.AsReadOnly() };
context.Iteration = iteration;
context.LastResponse = response;
// Stop when the agent is waiting for a tool approval.
if (HasPendingApprovalRequests(response))
{
yield break;
}
// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
this.LogMaxIterationsReached(iteration);
yield break;
}
// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
if (!step.ShouldContinue)
{
yield break;
}
currentMessages = step.Messages;
currentSurfaced = step.SurfacedMessages;
}
}
/// <summary>
/// Evaluates the evaluators in order and, for the first one that requests a re-invocation, builds the next input
/// according to the loop's feedback and fresh-context policy.
/// </summary>
private async ValueTask<LoopNextStep> EvaluateAndBuildNextAsync(LoopContext context, List<string?> feedbackLog, JsonElement? initialSessionSnapshot, CancellationToken cancellationToken)
{
// Evaluate in order; the first evaluator that requests a re-invocation wins.
LoopEvaluation? winner = null;
foreach (LoopEvaluator evaluator in this._evaluators)
{
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context, cancellationToken).ConfigureAwait(false);
if (evaluation.ShouldReinvoke)
{
winner = evaluation;
break;
}
}
// Every evaluator asked to stop.
if (winner is null)
{
return LoopNextStep.Stop();
}
// Start the next iteration from a fresh session when a fresh context is requested, so no prior conversation
// history leaks across iterations. This applies regardless of how the next input is built (feedback or explicit
// ContinueWithMessages): a caller-supplied session is cloned from the pristine start-of-run snapshot; a
// loop-owned session is created anew.
if (this._freshContextPerIteration)
{
context.Session = await this.CreateFreshIterationSessionAsync(context, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
}
// Record one feedback entry for this re-invoked iteration (null when none, including ContinueWithMessages
// iterations which carry no feedback string) so the log stays aligned: one entry per re-invoked iteration, with
// the last element always corresponding to the latest re-invoked iteration. Continue() normalizes whitespace to null.
feedbackLog.Add(winner.Feedback);
// An evaluator supplied explicit messages: send them verbatim, bypassing feedback/message construction (the
// session is still reset above when a fresh context is requested). These are surfaced to the caller as-is (the
// evaluator owns them, including any author name).
if (winner.Messages is not null)
{
return LoopNextStep.Continue(winner.Messages, this.Surfaced(winner.Messages));
}
(List<ChatMessage> messages, List<ChatMessage> surfaced) = this.BuildNextMessages(context, feedbackLog);
return LoopNextStep.Continue(messages, this.Surfaced(surfaced));
}
/// <summary>
/// Returns the messages to surface to the caller, honoring <see cref="LoopAgentOptions.ExcludeOnBehalfOfMessages"/>.
/// </summary>
private IReadOnlyList<ChatMessage> Surfaced(IReadOnlyList<ChatMessage> surfaced)
=> this._excludeOnBehalfOfMessages ? [] : surfaced;
/// <summary>
/// Creates a streaming update for a surfaced on-behalf-of message, inheriting the driven iteration's
/// <paramref name="responseId"/> so downstream mergers group it with that iteration, and ensuring a unique
/// non-null <see cref="AgentResponseUpdate.MessageId"/>. The <see cref="AgentResponseUpdate.AgentId"/> is left
/// unset because the message is synthesized by the loop, not produced by the wrapped agent.
/// </summary>
private static AgentResponseUpdate CreateOnBehalfOfUpdate(ChatMessage message, string? responseId)
=> new(message.Role, message.Contents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId is { Length: > 0 } messageId ? messageId : System.Guid.NewGuid().ToString("N"),
ResponseId = responseId,
};
/// <summary>
/// Builds the messages sent to the wrapped agent for the next iteration along with the subset that should be
/// surfaced to the caller (the loop-synthesized on-behalf-of feedback). Replayed caller input is excluded from the
/// surfaced subset.
/// </summary>
private (List<ChatMessage> Messages, List<ChatMessage> Surfaced) BuildNextMessages(LoopContext context, List<string?> feedback)
{
var messages = new List<ChatMessage>();
var surfaced = new List<ChatMessage>();
if (this._freshContextPerIteration)
{
// Fresh context: re-send the original task plus an aggregated log of all feedback recorded so far. Only the
// synthesized feedback message is surfaced; the replayed caller input messages are not.
messages.AddRange(context.InitialMessages);
ChatMessage? feedbackMessage = this.BuildAggregatedFeedbackMessage(feedback);
if (feedbackMessage is not null)
{
messages.Add(feedbackMessage);
surfaced.Add(feedbackMessage);
}
}
else
{
// Reused session: send only the latest feedback verbatim (the session already retains earlier turns). When
// the latest iteration produced no feedback, send no messages and let the agent continue from history.
string? latest = feedback.Count > 0 ? feedback[feedback.Count - 1] : null;
if (!string.IsNullOrWhiteSpace(latest))
{
var feedbackMessage = new ChatMessage(ChatRole.User, latest) { AuthorName = this._onBehalfOfAuthorName, MessageId = System.Guid.NewGuid().ToString("N") };
messages.Add(feedbackMessage);
surfaced.Add(feedbackMessage);
}
}
return (messages, surfaced);
}
private ChatMessage? BuildAggregatedFeedbackMessage(IReadOnlyList<string?> feedback)
{
var body = new StringBuilder("## Feedback\n");
bool any = false;
foreach (string? entry in feedback)
{
if (!string.IsNullOrWhiteSpace(entry))
{
body.Append("\n- ").Append(entry);
any = true;
}
}
return any ? new ChatMessage(ChatRole.User, body.ToString()) { AuthorName = this._onBehalfOfAuthorName, MessageId = System.Guid.NewGuid().ToString("N") } : null;
}
/// <summary>
/// Produces the non-streaming run result: either the final iteration's response (when configured) or an
/// aggregated response carrying the full transcript with the final response's metadata.
/// </summary>
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript)
{
if (this._nonStreamingReturnsLastResponseOnly)
{
return lastResponse;
}
return new AgentResponse(transcript)
{
AgentId = lastResponse.AgentId,
ResponseId = lastResponse.ResponseId,
CreatedAt = lastResponse.CreatedAt,
FinishReason = lastResponse.FinishReason,
Usage = lastResponse.Usage,
AdditionalProperties = lastResponse.AdditionalProperties,
ContinuationToken = lastResponse.ContinuationToken,
};
}
private static bool HasPendingApprovalRequests(AgentResponse response)
{
foreach (ChatMessage message in response.Messages)
{
foreach (AIContent content in message.Contents)
{
if (content is ToolApprovalRequestContent)
{
return true;
}
}
}
return false;
}
private void LogMaxIterationsReached(int iteration)
{
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation("LoopAgent reached the maximum of {MaxIterations} iterations and stopped.", iteration);
}
}
/// <summary>
/// Creates the session used for the next iteration when a fresh context is requested. A caller-supplied session is
/// restored from the pristine start-of-run snapshot by deserializing a fresh clone; a loop-owned session (no
/// snapshot) is created anew. The configured session-created callback is notified of the new session.
/// </summary>
private async ValueTask<AgentSession> CreateFreshIterationSessionAsync(LoopContext context, JsonElement? initialSessionSnapshot, CancellationToken cancellationToken)
{
AgentSession session = initialSessionSnapshot is { } snapshot
? await this.InnerAgent.DeserializeSessionAsync(snapshot, cancellationToken: cancellationToken).ConfigureAwait(false)
: await context.Agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false);
return session;
}
/// <summary>
/// Invokes the configured <see cref="LoopAgentOptions.SessionCreatedCallback"/> (if any) with a session the loop
/// has just created, so the caller can observe the latest session.
/// </summary>
private async ValueTask NotifyNewSessionAsync(AgentSession session, CancellationToken cancellationToken)
{
if (this._sessionCreatedCallback is not null)
{
await this._sessionCreatedCallback(session, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Represents the loop's decision for the next iteration: stop, or continue with a set of messages.</summary>
private readonly struct LoopNextStep
{
private LoopNextStep(bool shouldContinue, IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatMessage> surfacedMessages)
{
this.ShouldContinue = shouldContinue;
this.Messages = messages;
this.SurfacedMessages = surfacedMessages;
}
public bool ShouldContinue { get; }
/// <summary>Gets the full set of messages sent to the wrapped agent for the next iteration.</summary>
public IReadOnlyList<ChatMessage> Messages { get; }
/// <summary>
/// Gets the subset of <see cref="Messages"/> the loop synthesized on the caller's behalf (feedback or
/// evaluator-supplied messages) that should be surfaced to the caller. Replayed caller input is excluded.
/// </summary>
public IReadOnlyList<ChatMessage> SurfacedMessages { get; }
public static LoopNextStep Stop() => new(shouldContinue: false, [], []);
public static LoopNextStep Continue(IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatMessage> surfacedMessages)
=> new(shouldContinue: true, messages, surfacedMessages);
}
}
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides configuration options for <see cref="LoopAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class LoopAgentOptions
{
/// <summary>
/// Gets or sets the global safety cap on the number of times the wrapped agent is invoked in a single loop run,
/// or <see langword="null"/> to use <see cref="LoopAgent.DefaultMaxIterations"/>.
/// </summary>
/// <remarks>
/// This is an absolute upper bound that applies regardless of the configured <see cref="LoopEvaluator"/> set. An
/// evaluator may stop the loop earlier, but no evaluator can cause the loop to exceed this cap, so raise this value
/// if you intend to allow longer loops.
/// </remarks>
public int? MaxIterations { get; set; }
/// <summary>
/// Gets or sets a value indicating whether each re-invocation restarts from a clean context: the original input
/// messages plus an aggregated feedback log, rather than the latest feedback appended to the prior conversation.
/// Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// <para>
/// This rebuilds the input <em>messages</em> each iteration and resets the session before each re-invocation so no
/// prior conversation history leaks across iterations. When the loop owns the session it creates a new one each
/// iteration. When the caller supplies a session, <see cref="LoopAgent"/> serializes it once at the start of the run
/// and restores a fresh clone (by deserializing that snapshot) before each re-invocation; this requires the wrapped
/// agent to support session serialization. The first iteration still runs against the caller's supplied session.
/// </para>
/// <para>
/// Note that cloning will only result in a fresh context, if the chat history storage mechanism supports cloning.
/// For example the default in-memory storage supports cloning, since the messages are serialized as part of the snapshot.
/// </para>
/// <para>
/// However, if the Conversations service is used, which stores messages in a single threaded list of messages,
/// then the cloned session will still contain the full message history, since the snapshot only captures an id reference
/// to the conversation and not the individual messages.
/// </para>
/// <para>
/// On the other hand, if responses are used with response ids, cloning will work well, since response ids are
/// forkable. Each new response has its own id, and is based on the id of the previous response.
/// </para>
/// <para>
/// On iterations where an evaluator returns explicit messages via
/// <see cref="LoopEvaluation.ContinueWithMessages"/>, the session is still reset (a fresh or cloned session is
/// used); only the rebuild of the input messages from the feedback log is skipped, because the evaluator's explicit
/// messages are sent verbatim.
/// </para>
/// </remarks>
public bool FreshContextPerIteration { get; set; }
/// <summary>
/// Gets or sets the author name stamped on the loop-synthesized "on-behalf-of" messages that the loop injects
/// into the wrapped agent for re-invocations, or <see langword="null"/> to leave them unattributed. Defaults to
/// <see langword="null"/>.
/// </summary>
/// <remarks>
/// When the loop re-invokes the wrapped agent it sends feedback messages on the caller's behalf. Setting this name
/// marks those autonomous messages (for example with a value such as <c>"loop"</c>) so that callers and the wrapped
/// agent can distinguish them from the caller's own turns. It is applied only to messages the loop synthesizes
/// itself; messages supplied explicitly by an evaluator via <see cref="LoopEvaluation.ContinueWithMessages"/> are
/// left untouched, and the caller's original input messages are never modified.
/// </remarks>
public string? OnBehalfOfAuthorName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the on-behalf-of messages the loop injects for re-invocations are
/// omitted from the output surfaced back to the caller. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default) a streaming run emits the injected feedback / evaluator-supplied
/// messages as updates before each re-invocation, and a non-streaming run includes them in the aggregated
/// transcript, so callers can see the loop acting autonomously on their behalf. Set this to <see langword="true"/>
/// to omit those messages from the returned output and surface only the wrapped agent's responses; the messages are
/// still sent to the wrapped agent. This setting has no effect when
/// <see cref="NonStreamingReturnsLastResponseOnly"/> causes a non-streaming run to return only the final response.
/// </remarks>
public bool ExcludeOnBehalfOfMessages { get; set; }
/// <summary>
/// Gets or sets a value indicating whether a non-streaming run returns only the final iteration's response instead
/// of the aggregated transcript of every iteration. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// By default a non-streaming <see cref="LoopAgent"/> run returns a single <see cref="AgentResponse"/> that
/// aggregates, in order, the on-behalf-of messages the loop injected and the responses produced by every
/// iteration — mirroring the full sequence of updates yielded by a streaming run. Set this to <see langword="true"/>
/// to instead return only the last iteration's <see cref="AgentResponse"/>. This setting affects non-streaming runs
/// only; streaming runs always yield every iteration's updates.
/// </remarks>
public bool NonStreamingReturnsLastResponseOnly { get; set; }
/// <summary>
/// Gets or sets an optional callback invoked whenever <see cref="LoopAgent"/> creates a new session, so the caller
/// can capture the latest session (for example to continue the conversation after the loop completes). Defaults to
/// <see langword="null"/>.
/// </summary>
/// <remarks>
/// The callback is invoked with each session the loop itself creates: the initial loop-owned session (when the
/// caller does not supply one) and, when <see cref="FreshContextPerIteration"/> is enabled, every session created
/// for a re-invocation — whether a brand-new loop-owned session or a fresh clone deserialized from the caller's
/// original session. It is not invoked for a caller-supplied session, since the caller already holds that one. When
/// it fires multiple times, the most recent invocation carries the session the loop is currently using.
/// </remarks>
public Func<AgentSession, CancellationToken, ValueTask>? SessionCreatedCallback { get; set; }
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides the per-run state that a <see cref="LoopEvaluator"/> uses to decide whether a
/// <see cref="LoopAgent"/> should re-invoke the wrapped agent and what feedback to provide.
/// </summary>
/// <remarks>
/// A single <see cref="LoopContext"/> instance is created for each <see cref="LoopAgent"/> run and is
/// reused across iterations, with <see cref="Iteration"/> and <see cref="LastResponse"/> updated before
/// each call to <see cref="LoopEvaluator.EvaluateAsync"/>. Because evaluator instances are expected to be
/// stateless and may be shared across concurrent runs, any per-run mutable state must be stored on this
/// context — for example via <see cref="AdditionalProperties"/> — rather than in fields on the evaluator itself.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class LoopContext
{
/// <summary>
/// Initializes a new instance of the <see cref="LoopContext"/> class.
/// </summary>
/// <param name="agent">The wrapped <see cref="AIAgent"/> that is being looped.</param>
/// <param name="session">The <see cref="AgentSession"/> used for the loop.</param>
/// <param name="initialMessages">The messages passed in for the first iteration of the loop.</param>
/// <param name="lastResponse">The <see cref="AgentResponse"/> produced by the iteration that just completed.</param>
/// <param name="runOptions">The <see cref="AgentRunOptions"/> that were passed to the loop run, if any.</param>
/// <exception cref="System.ArgumentNullException">
/// <paramref name="agent"/>, <paramref name="session"/>, <paramref name="initialMessages"/>, or
/// <paramref name="lastResponse"/> is <see langword="null"/>.
/// </exception>
public LoopContext(
AIAgent agent,
AgentSession session,
IReadOnlyList<ChatMessage> initialMessages,
AgentResponse lastResponse,
AgentRunOptions? runOptions = null)
{
this.Agent = Throw.IfNull(agent);
this.Session = Throw.IfNull(session);
this.InitialMessages = Throw.IfNull(initialMessages);
this.LastResponse = Throw.IfNull(lastResponse);
this.RunOptions = runOptions;
}
/// <summary>Gets the wrapped <see cref="AIAgent"/> that is being looped.</summary>
public AIAgent Agent { get; }
/// <summary>Gets the <see cref="AgentSession"/> used for the loop.</summary>
/// <remarks>
/// When the caller does not provide a session, <see cref="LoopAgent"/> creates one up front. By default the same
/// session is reused across every iteration so that conversation continuity is preserved and the original request
/// is not replayed. When <see cref="LoopAgentOptions.FreshContextPerIteration"/> is enabled, <see cref="LoopAgent"/>
/// resets the session before each re-invocation: a loop-owned session is created anew, while a caller-supplied
/// session is restored from a snapshot taken at the start of the run by deserializing a fresh clone.
/// </remarks>
public AgentSession Session { get; internal set; }
/// <summary>Gets the messages that were passed in for the first iteration of the loop.</summary>
public IReadOnlyList<ChatMessage> InitialMessages { get; }
/// <summary>Gets the <see cref="AgentRunOptions"/> that were passed to the loop run, if any.</summary>
public AgentRunOptions? RunOptions { get; }
/// <summary>Gets the number of completed agent runs so far (1-based after the first run).</summary>
public int Iteration { get; internal set; }
/// <summary>Gets the <see cref="AgentResponse"/> produced by the iteration that just completed.</summary>
public AgentResponse LastResponse { get; internal set; }
/// <summary>
/// Gets the feedback accumulated across iterations so far, one entry per re-invoked iteration in order.
/// </summary>
/// <remarks>
/// Each entry is the feedback supplied by the evaluator that requested the corresponding re-invocation, or
/// <see langword="null"/> when that iteration produced no feedback string (for example a plain
/// <see cref="LoopEvaluation.Continue(string)"/> with no text, or a <see cref="LoopEvaluation.ContinueWithMessages"/>
/// that supplied explicit messages instead). The log records one entry per re-invoked iteration regardless of mode,
/// so the last entry always corresponds to the most recent re-invoked iteration. This log is owned and populated by
/// <see cref="LoopAgent"/>; evaluators may read it to reason over prior feedback.
/// </remarks>
public IReadOnlyList<string?> Feedback { get; internal set; } = [];
/// <summary>
/// Gets a mutable bag of per-run state shared across iterations and available to every evaluator.
/// </summary>
/// <remarks>
/// This dictionary is owned by the loop run (not by any evaluator instance) so that evaluators can remain
/// stateless. Evaluators can stash arbitrary per-run state here keyed by a collision-resistant key.
/// </remarks>
public AdditionalPropertiesDictionary AdditionalProperties { get; } = new();
}
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the result produced by a <see cref="LoopEvaluator"/> after an agent iteration: whether the
/// <see cref="LoopAgent"/> should re-invoke the wrapped agent and, optionally, the feedback or explicit messages that
/// should inform the next iteration.
/// </summary>
/// <remarks>
/// An evaluator is concerned only with the judgment (continue or stop) and what to carry forward. In the common case
/// it returns a feedback string and lets the <see cref="LoopAgent"/> decide how that feedback is turned into the next
/// input (and whether the session is reset). For full control, <see cref="ContinueWithMessages"/> supplies the exact
/// messages to send next, bypassing the loop's feedback and message construction.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class LoopEvaluation
{
private static readonly LoopEvaluation s_stop = new(shouldReinvoke: false, feedback: null, messages: null);
private LoopEvaluation(bool shouldReinvoke, string? feedback, IReadOnlyList<ChatMessage>? messages)
{
this.ShouldReinvoke = shouldReinvoke;
this.Feedback = feedback;
this.Messages = messages;
}
/// <summary>Gets a value indicating whether the loop should run the wrapped agent again.</summary>
public bool ShouldReinvoke { get; }
/// <summary>
/// Gets the feedback describing what is missing or what the agent should do next, or <see langword="null"/> when
/// no feedback was produced.
/// </summary>
/// <remarks>This value is only meaningful when <see cref="ShouldReinvoke"/> is <see langword="true"/>.</remarks>
public string? Feedback { get; }
/// <summary>
/// Gets the explicit messages to send on the next iteration, or <see langword="null"/> when the loop should build
/// the next input from feedback instead.
/// </summary>
/// <remarks>
/// When non-<see langword="null"/>, the <see cref="LoopAgent"/> sends these messages verbatim and does not apply
/// its feedback or message construction. The session is still reset when
/// <see cref="LoopAgentOptions.FreshContextPerIteration"/> is enabled. Only meaningful when
/// <see cref="ShouldReinvoke"/> is <see langword="true"/>.
/// </remarks>
internal IReadOnlyList<ChatMessage>? Messages { get; }
/// <summary>Creates an evaluation that stops the loop and returns the latest response to the caller.</summary>
/// <returns>An evaluation with <see cref="ShouldReinvoke"/> set to <see langword="false"/>.</returns>
public static LoopEvaluation Stop() => s_stop;
/// <summary>Creates an evaluation that re-invokes the wrapped agent, optionally carrying feedback forward.</summary>
/// <param name="feedback">
/// Optional feedback to inform the next iteration. <see langword="null"/>, empty, or whitespace is treated as no
/// feedback.
/// </param>
/// <returns>An evaluation with <see cref="ShouldReinvoke"/> set to <see langword="true"/>.</returns>
public static LoopEvaluation Continue(string? feedback = null) => new(shouldReinvoke: true, string.IsNullOrWhiteSpace(feedback) ? null : feedback, messages: null);
/// <summary>
/// Creates an evaluation that re-invokes the wrapped agent with the specified messages, bypassing the loop's
/// feedback and message construction.
/// </summary>
/// <param name="messages">The messages to send to the wrapped agent on the next iteration.</param>
/// <returns>An evaluation with <see cref="ShouldReinvoke"/> set to <see langword="true"/>.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Use this for full control over the next input (for example to send non-user roles, multiple messages, or
/// non-text content). The supplied messages are sent verbatim and the loop does not accumulate or inject feedback
/// for this iteration.
/// </remarks>
public static LoopEvaluation ContinueWithMessages(IEnumerable<ChatMessage> messages)
{
_ = Throw.IfNull(messages);
return new LoopEvaluation(shouldReinvoke: true, feedback: null, messages: messages as IReadOnlyList<ChatMessage> ?? messages.ToList());
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides the abstract base class for the component that decides, after each agent iteration, whether a
/// <see cref="LoopAgent"/> should re-invoke the wrapped agent and what feedback to provide.
/// </summary>
/// <remarks>
/// <para>
/// A <see cref="LoopEvaluator"/> is pure judgment: it inspects the <see cref="LoopContext"/> and returns a
/// <see cref="LoopEvaluation"/> describing whether to continue and any feedback for the next iteration. It does not
/// manage the session or construct the next input messages — that is the responsibility of the
/// <see cref="LoopAgent"/> that consumes it.
/// </para>
/// <para>
/// Out-of-the-box implementations include <see cref="AIJudgeLoopEvaluator"/>, <see cref="DelegateLoopEvaluator"/>,
/// <see cref="CompletionMarkerLoopEvaluator"/>, and <see cref="TodoCompletionLoopEvaluator"/>. Implementations should be stateless and safe to share across
/// concurrent loop runs; any per-run state must be stored on the supplied <see cref="LoopContext"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class LoopEvaluator
{
/// <summary>
/// Evaluates the loop state after an iteration and decides whether to re-invoke the wrapped agent and what
/// feedback to provide.
/// </summary>
/// <param name="context">The per-run <see cref="LoopContext"/> describing the current loop state.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A value task whose result is a <see cref="LoopEvaluation"/> indicating whether to continue and, if so, the
/// feedback to carry forward to the next iteration.
/// </returns>
public abstract ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI;
/// <summary>
/// Source-generated <see cref="JsonSerializerContext"/> for loop types that require JSON serialization, such as the
/// structured <see cref="JudgeVerdict"/> used by <see cref="AIJudgeLoopEvaluator"/>.
/// </summary>
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(JudgeVerdict))]
[ExcludeFromCodeCoverage]
internal sealed partial class LoopJsonContext : JsonSerializerContext;
@@ -0,0 +1,149 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="LoopEvaluator"/> that keeps re-invoking the wrapped agent until a <see cref="TodoProvider"/> has no
/// remaining (incomplete) todo items, optionally only while the agent is operating in one of a configured set of modes
/// tracked by an <see cref="AgentModeProvider"/>.
/// </summary>
/// <remarks>
/// <para>
/// The required <see cref="TodoProvider"/> — and, when modes are configured, the <see cref="AgentModeProvider"/> — are
/// not supplied directly. They are resolved at evaluation time from the looped agent via
/// <see cref="AIAgent.GetService{TService}(object?)"/>. This works because an agent surfaces its registered
/// <see cref="AIContextProvider"/> instances through <c>GetService</c>, so a single <see cref="TodoProvider"/> (and
/// <see cref="AgentModeProvider"/>) attached to the agent's session is discovered automatically. It also means this
/// evaluator can be added directly to a harness agent's loop without any additional wiring.
/// </para>
/// <para>
/// When one or more modes are configured, the evaluator only requests re-invocation while the session's current mode is
/// one of those modes; in any other mode it returns <see cref="LoopEvaluation.Stop"/> (which, per <see cref="LoopAgent"/>
/// semantics, declines to drive continuation rather than vetoing other evaluators). When no modes are configured the
/// evaluator applies in every mode and no <see cref="AgentModeProvider"/> is required.
/// </para>
/// <para>
/// While incomplete todos remain the evaluator continues with feedback built from a template (see
/// <see cref="TodoCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) with the remaining todo list substituted
/// for <see cref="RemainingTodosPlaceholder"/>. How that feedback is delivered to the agent (and whether the session is
/// reset) is decided by the <see cref="LoopAgent"/> that consumes this evaluator.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoCompletionLoopEvaluator : LoopEvaluator
{
/// <summary>
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
/// <see cref="TodoCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced, on each evaluation,
/// with a formatted list of the remaining (incomplete) todo items.
/// </summary>
public const string RemainingTodosPlaceholder = "{remaining_todos}";
/// <summary>The default template used to build the feedback produced while incomplete todo items remain.</summary>
public const string DefaultFeedbackMessageTemplate =
"You still have incomplete todo items. Continue working until every item is complete, marking each item as " +
"complete when finished. The following items are still open:\n" + RemainingTodosPlaceholder;
private readonly HashSet<string>? _modes;
private readonly string _feedbackMessageTemplate;
/// <summary>
/// Initializes a new instance of the <see cref="TodoCompletionLoopEvaluator"/> class.
/// </summary>
/// <param name="options">
/// Optional configuration for the evaluator, including <see cref="TodoCompletionLoopEvaluatorOptions.Modes"/> and
/// the feedback message template. When <see langword="null"/>, defaults are used (applies in every mode).
/// </param>
/// <exception cref="ArgumentException">
/// <see cref="TodoCompletionLoopEvaluatorOptions.Modes"/> is non-<see langword="null"/> but empty, or contains a
/// <see langword="null"/>, empty, or whitespace mode name.
/// </exception>
public TodoCompletionLoopEvaluator(TodoCompletionLoopEvaluatorOptions? options = null)
{
if (options?.Modes is not null)
{
var modeSet = new HashSet<string>(StringComparer.Ordinal);
foreach (string mode in options.Modes)
{
if (string.IsNullOrWhiteSpace(mode))
{
throw new ArgumentException("Mode names must not be null, empty, or whitespace.", nameof(options));
}
modeSet.Add(mode);
}
if (modeSet.Count == 0)
{
throw new ArgumentException("At least one mode must be supplied when modes are specified. Leave Modes null to apply in every mode.", nameof(options));
}
this._modes = modeSet;
}
this._feedbackMessageTemplate = options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate;
}
/// <inheritdoc />
public override async ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
TodoProvider todoProvider = context.Agent.GetService<TodoProvider>()
?? throw new InvalidOperationException(
$"{nameof(TodoCompletionLoopEvaluator)} requires a {nameof(TodoProvider)} to be registered on the agent, but none could be resolved via GetService.");
// When modes are configured, only drive re-invocation while the current mode is one of them.
if (this._modes is not null)
{
AgentModeProvider modeProvider = context.Agent.GetService<AgentModeProvider>()
?? throw new InvalidOperationException(
$"{nameof(TodoCompletionLoopEvaluator)} was configured with modes but no {nameof(AgentModeProvider)} could be resolved from the agent via GetService.");
string currentMode = modeProvider.GetMode(context.Session);
if (!this._modes.Contains(currentMode))
{
return LoopEvaluation.Stop();
}
}
List<TodoItem> remaining = await todoProvider.GetRemainingTodosAsync(context.Session, cancellationToken).ConfigureAwait(false);
if (remaining.Count == 0)
{
return LoopEvaluation.Stop();
}
string feedback = this._feedbackMessageTemplate.Replace(RemainingTodosPlaceholder, FormatRemainingTodos(remaining));
return LoopEvaluation.Continue(feedback);
}
private static string FormatRemainingTodos(List<TodoItem> remaining)
{
var sb = new StringBuilder();
for (int i = 0; i < remaining.Count; i++)
{
TodoItem item = remaining[i];
sb.Append("- ").Append(item.Id).Append(": ").Append(item.Title);
if (!string.IsNullOrWhiteSpace(item.Description))
{
sb.Append(" — ").Append(item.Description);
}
if (i < remaining.Count - 1)
{
sb.Append('\n');
}
}
return sb.ToString();
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides configuration options for <see cref="TodoCompletionLoopEvaluator"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoCompletionLoopEvaluatorOptions
{
/// <summary>
/// Gets or sets the set of mode names for which the evaluator drives re-invocation, or <see langword="null"/> to
/// apply in every mode.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the evaluator applies in every mode and no <see cref="AgentModeProvider"/> is
/// required. When non-<see langword="null"/> it must contain at least one non-empty mode name; mode names are
/// matched ordinally and an <see cref="AgentModeProvider"/> must be resolvable from the agent at evaluation time.
/// </remarks>
public IEnumerable<string>? Modes { get; set; }
/// <summary>
/// Gets or sets the template used to build the feedback produced while incomplete todo items remain,
/// or <see langword="null"/> to use <see cref="TodoCompletionLoopEvaluator.DefaultFeedbackMessageTemplate"/>.
/// </summary>
/// <remarks>
/// Any occurrence of <see cref="TodoCompletionLoopEvaluator.RemainingTodosPlaceholder"/> in the template is
/// replaced, on each evaluation, with a formatted list of the remaining (incomplete) todo items. When the
/// placeholder is absent the rendered list is not appended.
/// </remarks>
public string? FeedbackMessageTemplate { get; set; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the input for completing a single todo item via the <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class TodoCompleteInput
{
/// <summary>
/// Gets or sets the ID of the todo item to mark as complete.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the reason describing how or why the item was completed.
/// </summary>
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single todo item managed by the <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoItem
{
/// <summary>
/// Gets or sets the unique identifier for this todo item.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the title of this todo item.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an optional description providing additional details about this todo item.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this todo item has been completed.
/// </summary>
[JsonPropertyName("isComplete")]
public bool IsComplete { get; set; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the input for creating a new todo item via the <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class TodoItemInput
{
/// <summary>
/// Gets or sets the title of the todo item to create.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an optional description providing additional details about the todo item.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
}
@@ -0,0 +1,380 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that provides todo management tools and instructions
/// to an agent for tracking work items during long-running complex tasks.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="TodoProvider"/> enables agents to create, complete, remove, and query todo items
/// as part of their planning and execution workflow. Todo state is stored in the session's
/// <see cref="AgentSessionStateBag"/> and persists across agent invocations within the same session.
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
/// </list>
/// </para>
/// <para>
/// All operations are thread-safe; concurrent reads and mutations on the same session are serialized
/// using a per-session lock to prevent duplicate IDs, lost updates, or inconsistent reads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoProvider : AIContextProvider, IDisposable
{
private const string DefaultInstructions =
"""
## Todo Items
You have access to a todo list for tracking work items.
When a user asks you to perform a task, follow these steps to manage your work:
1. Determine whether the ask requires multiple steps to complete (complex) or can be completed using a single step (simple).
2. If complex, turn the task into manageable todo items and add them to the list.
3. If simple, don't add a todo item, but rather just complete the task directly.
### General TODO Guidelines
Ask questions from the user where clarification is needed to create effective todos.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
When a user changes the topic, changes their mind or switches to a new request, ensure that you update the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.
Use these tools to manage your tasks:
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
- Use todos_get_remaining to check what work is still pending.
- Use todos_get_all to review the full list including completed items.
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
""";
private readonly ProviderSessionState<TodoState> _sessionState;
private readonly string _instructions;
private readonly bool _suppressTodoListMessage;
private readonly Func<IReadOnlyList<TodoItem>, string>? _todoListMessageBuilder;
private readonly ConditionalWeakTable<AgentSession, SemaphoreSlim> _sessionLocks = new();
private readonly SemaphoreSlim _nullSessionLock = new(1, 1);
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="TodoProvider"/> class.
/// </summary>
/// <param name="options">Optional settings that control provider behavior. When <see langword="null"/>, defaults are used.</param>
public TodoProvider(TodoProviderOptions? options = null)
{
this._instructions = options?.Instructions ?? DefaultInstructions;
this._suppressTodoListMessage = options?.SuppressTodoListMessage ?? false;
this._todoListMessageBuilder = options?.TodoListMessageBuilder;
this._sessionState = new ProviderSessionState<TodoState>(
_ => new TodoState(),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <inheritdoc />
public void Dispose()
{
this._nullSessionLock.Dispose();
}
/// <summary>
/// Gets all todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A list of all todo items. The items are live references to internal state.</returns>
public async Task<IReadOnlyList<TodoItem>> GetAllTodosAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.ToList();
}
finally
{
sessionLock.Release();
}
}
/// <summary>
/// Gets the remaining (incomplete) todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A list of incomplete todo items. The items are live references to internal state.</returns>
public async Task<List<TodoItem>> GetRemainingTodosAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.Where(t => !t.IsComplete).ToList();
}
finally
{
sessionLock.Release();
}
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var aiContext = new AIContext
{
Instructions = this._instructions,
Tools = this.CreateTools(context.Session),
};
if (!this._suppressTodoListMessage)
{
// Inject a synthetic user message summarizing the current todo list so the agent
// is aware of outstanding work at the start of each invocation.
SemaphoreSlim sessionLock = this.GetSessionLock(context.Session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
List<TodoItem> currentItems;
try
{
TodoState state = this._sessionState.GetOrInitializeState(context.Session);
currentItems = state.Items.ToList();
}
finally
{
sessionLock.Release();
}
string message = this._todoListMessageBuilder is not null
? this._todoListMessageBuilder(currentItems)
: FormatTodoListMessage(currentItems);
aiContext.Messages =
[
new ChatMessage(ChatRole.User, message),
];
}
return aiContext;
}
/// <summary>
/// Returns the per-session semaphore used to serialize all todo operations.
/// </summary>
private SemaphoreSlim GetSessionLock(AgentSession? session)
{
if (session is null)
{
return this._nullSessionLock;
}
return this._sessionLocks.GetValue(session, _ => new SemaphoreSlim(1, 1));
}
private AITool[] CreateTools(AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
async (List<TodoItemInput> todos) =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
var created = new List<TodoItem>();
foreach (var input in todos)
{
var item = new TodoItem
{
Id = state.NextId++,
Title = input.Title.Trim(),
Description = input.Description?.Trim(),
};
state.Items.Add(item);
created.Add(item);
}
this._sessionState.SaveState(session, state);
return created;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "todos_add",
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async (List<TodoCompleteInput> items) =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(items.Select(i => i.Id));
int completed = 0;
foreach (TodoItem item in state.Items)
{
if (!item.IsComplete && idSet.Contains(item.Id))
{
item.IsComplete = true;
completed++;
}
}
if (completed > 0)
{
this._sessionState.SaveState(session, state);
}
return completed;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "todos_complete",
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async (List<int> ids) =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(ids);
int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
if (removed > 0)
{
this._sessionState.SaveState(session, state);
}
return removed;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "todos_remove",
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async () =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.Where(t => !t.IsComplete).ToList();
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "todos_get_remaining",
Description = "Retrieve the list of incomplete todo items.",
SerializerOptions = serializerOptions,
}),
AIFunctionFactory.Create(
async () =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.ToList();
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "todos_get_all",
Description = "Retrieve the full list of todo items, both complete and incomplete.",
SerializerOptions = serializerOptions,
}),
];
}
internal static string FormatTodoListMessage(List<TodoItem> items)
{
if (items.Count == 0)
{
return "### Current todo list\n- none yet";
}
var sb = new StringBuilder("### Current todo list\n");
foreach (var item in items)
{
string status = item.IsComplete ? "done" : "open";
sb.Append($"- {item.Id} [{status}] {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Description))
{
sb.Append($": {item.Description}");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the todo tools.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to manage todos effectively.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to suppress injecting the todo list message
/// into the conversation context.
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), a synthetic user message summarizing the current
/// todo list is injected at each invocation. When <see langword="true"/>, no message is injected.
/// </value>
public bool SuppressTodoListMessage { get; set; }
/// <summary>
/// Gets or sets a custom function that builds the todo list message text.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard formatted list
/// of todo items. When set, this function receives the current list of todo items and should
/// return a formatted string to inject as a user message.
/// </value>
public Func<IReadOnlyList<TodoItem>, string>? TodoListMessageBuilder { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the state of the todo list managed by the <see cref="TodoProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class TodoState
{
/// <summary>
/// Gets the list of todo items.
/// </summary>
[JsonPropertyName("items")]
public List<TodoItem> Items { get; set; } = [];
/// <summary>
/// Gets or sets the next ID to assign to a new todo item.
/// </summary>
[JsonPropertyName("nextId")]
public int NextId { get; set; } = 1;
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Wraps a <see cref="ToolApprovalResponseContent"/> with additional "always approve" settings,
/// enabling the <see cref="ToolApprovalAgent"/> middleware to record standing approval rules
/// so that future matching tool calls are auto-approved without user interaction.
/// </summary>
/// <remarks>
/// <para>
/// Instances of this class should not be created directly. Instead, use the extension methods
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolResponse"/> or
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolWithArgumentsResponse"/>
/// on <see cref="ToolApprovalRequestContent"/> to create instances with the appropriate flags set.
/// </para>
/// <para>
/// The <see cref="ToolApprovalAgent"/> middleware will unwrap the <see cref="InnerResponse"/> to forward
/// to the inner agent, while extracting the approval settings to persist as <see cref="ToolApprovalRule"/>
/// entries in the session state.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AlwaysApproveToolApprovalResponseContent : AIContent
{
/// <summary>
/// Initializes a new instance of the <see cref="AlwaysApproveToolApprovalResponseContent"/> class.
/// </summary>
/// <param name="innerResponse">The underlying approval response to forward to the agent.</param>
/// <param name="alwaysApproveTool">
/// When <see langword="true"/>, all future calls to this tool type will be auto-approved.
/// </param>
/// <param name="alwaysApproveToolWithArguments">
/// When <see langword="true"/>, all future calls to this tool type with the same arguments will be auto-approved.
/// </param>
internal AlwaysApproveToolApprovalResponseContent(
ToolApprovalResponseContent innerResponse,
bool alwaysApproveTool,
bool alwaysApproveToolWithArguments)
{
this.InnerResponse = Throw.IfNull(innerResponse);
this.AlwaysApproveTool = alwaysApproveTool;
this.AlwaysApproveToolWithArguments = alwaysApproveToolWithArguments;
}
/// <summary>
/// Gets the underlying <see cref="ToolApprovalResponseContent"/> that will be forwarded to the inner agent.
/// </summary>
public ToolApprovalResponseContent InnerResponse { get; }
/// <summary>
/// Gets a value indicating whether all future calls to the same tool should be auto-approved
/// regardless of the arguments provided.
/// </summary>
public bool AlwaysApproveTool { get; }
/// <summary>
/// Gets a value indicating whether all future calls to the same tool with the exact same
/// arguments should be auto-approved.
/// </summary>
public bool AlwaysApproveToolWithArguments { get; }
}
@@ -0,0 +1,855 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// A <see cref="DelegatingAIAgent"/> middleware that implements "don't ask again" tool approval behavior
/// and queues multiple approval requests to present them to the caller one at a time.
/// </summary>
/// <remarks>
/// <para>
/// This middleware intercepts the approval flow between the caller and the inner agent:
/// </para>
/// <list type="bullet">
/// <item>
/// <b>Outbound (response to caller):</b> When the inner agent surfaces <see cref="ToolApprovalRequestContent"/> items,
/// the middleware checks whether matching <see cref="ToolApprovalRule"/> entries have been recorded. Matched requests
/// are auto-approved and stored as collected approval responses. If multiple unapproved requests remain, only the
/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated
/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time.
/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again.
/// </item>
/// <item>
/// <b>Inbound (caller to agent):</b> When the caller sends an <see cref="AlwaysApproveToolApprovalResponseContent"/>,
/// the middleware extracts the standing approval settings, records them as <see cref="ToolApprovalRule"/> entries
/// in the session state, and forwards only the unwrapped <see cref="ToolApprovalResponseContent"/> to the inner agent.
/// Content ordering within each message is preserved.
/// </item>
/// </list>
/// <para>
/// Approval rules are persisted in the <see cref="AgentSessionStateBag"/> and survive across agent runs within the same session.
/// Two categories of rules are supported:
/// </para>
/// <list type="bullet">
/// <item><b>Tool-level:</b> Approve all calls to a specific tool, regardless of arguments.</item>
/// <item><b>Tool+arguments:</b> Approve all calls to a specific tool with exactly matching arguments.</item>
/// </list>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly Func<FunctionCallContent, ValueTask<bool>>[]? _autoApprovalRules;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to delegate to.</param>
/// <param name="options">
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
/// When <see langword="null"/>, default settings are used.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public ToolApprovalAgent(AIAgent innerAgent, ToolApprovalAgentOptions? options = null)
: base(innerAgent)
{
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
this._sessionState = new ProviderSessionState<ToolApprovalState>(
_ => new ToolApprovalState(),
"toolApprovalState",
this._jsonSerializerOptions);
}
/// <summary>
/// Gets an auto-approval rule that approves every tool call, regardless of tool name or arguments.
/// </summary>
/// <remarks>
/// <para>
/// Add this rule to <see cref="ToolApprovalAgentOptions.AutoApprovalRules"/> to automatically approve all
/// tool calls without prompting the user. This effectively disables approval prompts for every tool, so use
/// it only when running in a fully trusted context.
/// </para>
/// <para>
/// For example, to auto-approve every tool call:
/// <code>
/// builder.UseToolApproval(new ToolApprovalAgentOptions
/// {
/// AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule],
/// });
/// </code>
/// </para>
/// </remarks>
public static Func<FunctionCallContent, ValueTask<bool>> AllToolsAutoApprovalRule { get; } =
_ => new ValueTask<bool>(true);
/// <inheritdoc />
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Steps 12: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
if (nextQueuedItem is not null)
{
// Queue still has items — return the next one to the caller for approval.
return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem]));
}
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
// that are ALL auto-approved by standing rules, we immediately re-call with the
// collected approval responses injected. This avoids returning empty responses.
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session).ConfigureAwait(false);
if (!allAutoApproved)
{
// Response has real content or an unapproved approval request — return to caller.
return response;
}
// All approval requests were auto-approved. Loop to re-invoke with them injected.
callerMessages = [];
}
}
/// <inheritdoc />
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Steps 12: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
if (nextQueuedItem is not null)
{
// Queue still has items — yield the next one to the caller for approval.
yield return new AgentResponseUpdate(ChatRole.Assistant, [nextQueuedItem]);
yield break;
}
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
// are auto-approved by standing rules, we immediately re-stream with the collected
// approval responses injected. This avoids returning empty streams.
while (true)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
// Stream from the inner agent. Non-approval content is yielded immediately.
// Approval requests are collected (not yielded) so we can classify the full batch.
List<ToolApprovalRequestContent> streamedApprovalRequests = [];
await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
// Fast path: no approval content in this update — yield as-is.
bool hasApprovalRequests = false;
foreach (var content in update.Contents)
{
if (content is ToolApprovalRequestContent)
{
hasApprovalRequests = true;
break;
}
}
if (!hasApprovalRequests)
{
yield return update;
continue;
}
// Split the update: collect approval requests, keep other content.
var filteredContents = new List<AIContent>();
foreach (var content in update.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
streamedApprovalRequests.Add(tarc);
}
else
{
filteredContents.Add(content);
}
}
// Yield the non-approval portion of the update (if any) as a cloned update.
if (filteredContents.Count > 0)
{
yield return new AgentResponseUpdate(update.Role, filteredContents)
{
AuthorName = update.AuthorName,
AdditionalProperties = update.AdditionalProperties,
AgentId = update.AgentId,
ResponseId = update.ResponseId,
MessageId = update.MessageId,
CreatedAt = update.CreatedAt,
ContinuationToken = update.ContinuationToken,
FinishReason = update.FinishReason,
RawRepresentation = update.RawRepresentation,
};
}
}
// If the stream contained no approval requests, we're done.
if (streamedApprovalRequests.Count == 0)
{
yield break;
}
// 4. Classify the collected approval requests against standing rules and auto-approval rules.
List<ToolApprovalRequestContent> unapproved = [];
foreach (var tarc in streamedApprovalRequests)
{
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
}
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
}
else
{
unapproved.Add(tarc);
}
}
// If all were auto-approved, loop to re-invoke the inner agent with them injected.
if (unapproved.Count == 0)
{
callerMessages = [];
continue;
}
// 5. Queue excess unapproved requests and yield only the first to the caller.
if (unapproved.Count > 1)
{
state.QueuedApprovalRequests.AddRange(unapproved.GetRange(1, unapproved.Count - 1));
}
this._sessionState.SaveState(session, state);
yield return new AgentResponseUpdate(ChatRole.Assistant, [unapproved[0]]);
yield break;
}
}
/// <summary>
/// Extracts <see cref="ToolApprovalResponseContent"/> instances from the caller's messages
/// and collects them into <see cref="ToolApprovalState.CollectedApprovalResponses"/>.
/// Extracted responses are removed from the messages in-place.
/// </summary>
private static void CollectApprovalResponsesFromMessages(
List<ChatMessage> messages,
ToolApprovalState state)
{
// Walk messages in reverse so we can safely remove by index.
for (int i = messages.Count - 1; i >= 0; i--)
{
var message = messages[i];
// Quick check: does this message contain any approval responses?
bool hasApprovalResponse = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalResponseContent)
{
hasApprovalResponse = true;
break;
}
}
if (!hasApprovalResponse)
{
continue;
}
// Separate approval responses (→ state) from other content (→ keep in message).
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is ToolApprovalResponseContent response)
{
state.CollectedApprovalResponses.Add(response);
}
else
{
remaining.Add(content);
}
}
// Remove the message entirely if it only contained approval responses,
// otherwise replace it with a clone that has the approval responses stripped.
if (remaining.Count == 0)
{
messages.RemoveAt(i);
}
else
{
var cloned = message.Clone();
cloned.Contents = remaining;
messages[i] = cloned;
}
}
}
/// <summary>
/// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match.
/// </summary>
private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState state)
{
for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--)
{
if (MatchesRule(state.QueuedApprovalRequests[i], state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
state.QueuedApprovalRequests.RemoveAt(i);
}
else if (await this.MatchesAutoApprovalRuleAsync(state.QueuedApprovalRequests[i]).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
state.QueuedApprovalRequests.RemoveAt(i);
}
}
}
/// <summary>
/// Performs the common inbound processing shared by both the streaming and non-streaming paths:
/// <list type="number">
/// <item>Unwraps <see cref="AlwaysApproveToolApprovalResponseContent"/> wrappers, extracting standing rules.</item>
/// <item>If there are queued approval requests from a previous batch, collects the caller's responses,
/// drains any items now resolvable by new rules, and dequeues the next item if any remain.</item>
/// </list>
/// </summary>
/// <returns>
/// A tuple of (state, processed caller messages, next queued item or <see langword="null"/> if the queue is resolved).
/// When the returned item is non-null, the caller should return/yield it without calling the inner agent.
/// </returns>
private async ValueTask<(ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)>
PrepareInboundMessagesAsync(IEnumerable<ChatMessage> messages, AgentSession? session)
{
var state = this._sessionState.GetOrInitializeState(session);
// 1. Unwrap any AlwaysApprove wrappers in the caller's messages.
// This extracts standing approval rules into state and replaces wrappers with plain responses.
var callerMessages = UnwrapAlwaysApproveResponses(messages, state, this._jsonSerializerOptions);
// 2. If there are queued approval requests from a previous batch, handle them
// before calling the inner agent.
if (state.QueuedApprovalRequests.Count > 0)
{
// Collect the caller's approval/denial responses for the previously dequeued item
// and store them in state for the next downstream call.
CollectApprovalResponsesFromMessages(callerMessages, state);
// Re-evaluate remaining queued items — the caller may have added new rules
// (e.g., "always approve this tool") that resolve additional items.
await this.DrainAutoApprovableFromQueueAsync(state).ConfigureAwait(false);
if (state.QueuedApprovalRequests.Count > 0)
{
// More items remain — dequeue the next one for the caller.
var next = state.QueuedApprovalRequests[0];
state.QueuedApprovalRequests.RemoveAt(0);
this._sessionState.SaveState(session, state);
return (state, callerMessages, next);
}
// Queue fully resolved — caller should proceed to call the inner agent.
}
return (state, callerMessages, null);
}
/// <summary>
/// Injects any collected approval responses as user messages before the caller's messages,
/// then clears the collected responses.
/// </summary>
private List<ChatMessage> InjectCollectedResponses(
List<ChatMessage> callerMessages,
ToolApprovalState state,
AgentSession? session)
{
if (state.CollectedApprovalResponses.Count > 0)
{
List<ChatMessage> result = [new ChatMessage(ChatRole.User, [.. state.CollectedApprovalResponses])];
result.AddRange(callerMessages);
state.CollectedApprovalResponses.Clear();
this._sessionState.SaveState(session, state);
return result;
}
return callerMessages;
}
/// <summary>
/// Processes outbound approval requests from non-streaming response messages.
/// Auto-approvable requests are collected as responses, and if multiple unapproved requests
/// remain, only the first is kept in the response while the rest are queued for subsequent calls.
/// </summary>
/// <returns>
/// <see langword="true"/> if all TARc items were auto-approved (caller should re-invoke the inner agent);
/// <see langword="false"/> otherwise.
/// </returns>
private async ValueTask<bool> ProcessAndQueueOutboundApprovalRequestsAsync(
IList<ChatMessage> responseMessages,
ToolApprovalState state,
AgentSession? session)
{
// Pass 1: Scan all response messages and classify each approval request.
// Auto-approved requests (matching a standing rule or auto-approval rule) have their
// responses collected immediately, preserving the original request order, and are
// marked for removal. Unapproved requests are collected for the caller to decide.
var toRemove = new HashSet<ToolApprovalRequestContent>();
var unapproved = new List<ToolApprovalRequestContent>();
int autoApprovedCount = 0;
foreach (var message in responseMessages)
{
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
toRemove.Add(tarc);
autoApprovedCount++;
}
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
toRemove.Add(tarc);
autoApprovedCount++;
}
else
{
unapproved.Add(tarc);
}
}
}
}
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
// No responses were collected above in this case, so state is unmodified and safe to leave.
if (autoApprovedCount == 0 && unapproved.Count <= 1)
{
return false;
}
// If every approval request was auto-approved, strip them all and signal the caller
// to re-invoke the inner agent immediately with the collected responses.
if (unapproved.Count == 0)
{
RemoveAllToolApprovalRequests(responseMessages);
this._sessionState.SaveState(session, state);
return true;
}
// Pass 2: Keep only the first unapproved request in the response (for the caller to decide).
// Queue the remaining unapproved requests for subsequent one-at-a-time delivery.
// Remove all auto-approved and queued items from the response messages.
for (int i = 1; i < unapproved.Count; i++)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
}
// Walk messages in reverse and strip marked items.
for (int i = responseMessages.Count - 1; i >= 0; i--)
{
var message = responseMessages[i];
// Quick check: does this message contain any items to remove?
bool hasRemovable = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc))
{
hasRemovable = true;
break;
}
}
if (!hasRemovable)
{
continue;
}
// Filter out the marked items, keeping everything else.
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc))
{
continue;
}
remaining.Add(content);
}
// Remove the message entirely if it's now empty, otherwise replace with filtered clone.
if (remaining.Count == 0)
{
responseMessages.RemoveAt(i);
}
else
{
var clonedMessage = message.Clone();
clonedMessage.Contents = remaining;
responseMessages[i] = clonedMessage;
}
}
this._sessionState.SaveState(session, state);
return false;
}
/// <summary>
/// Removes all <see cref="ToolApprovalRequestContent"/> items from response messages.
/// </summary>
private static void RemoveAllToolApprovalRequests(IList<ChatMessage> responseMessages)
{
// Walk messages in reverse so we can safely remove by index.
for (int i = responseMessages.Count - 1; i >= 0; i--)
{
var message = responseMessages[i];
// Quick check: does this message contain any approval requests?
bool hasTarc = false;
foreach (var content in message.Contents)
{
if (content is ToolApprovalRequestContent)
{
hasTarc = true;
break;
}
}
if (!hasTarc)
{
continue;
}
// Keep only non-approval content.
var remaining = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is not ToolApprovalRequestContent)
{
remaining.Add(content);
}
}
// Remove the message entirely if it's now empty, otherwise replace with filtered clone.
if (remaining.Count == 0)
{
responseMessages.RemoveAt(i);
}
else
{
var clonedMessage = message.Clone();
clonedMessage.Contents = remaining;
responseMessages[i] = clonedMessage;
}
}
}
/// <summary>
/// Scans input messages for <see cref="AlwaysApproveToolApprovalResponseContent"/> instances,
/// extracts standing approval rules, and replaces them in-place with the unwrapped inner
/// <see cref="ToolApprovalResponseContent"/>, preserving content ordering.
/// </summary>
private static List<ChatMessage> UnwrapAlwaysApproveResponses(
IEnumerable<ChatMessage> messages,
ToolApprovalState state,
JsonSerializerOptions jsonSerializerOptions)
{
var messageList = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
var result = new List<ChatMessage>(messageList.Count);
bool anyModified = false;
foreach (var message in messageList)
{
// Quick check: does this message contain any AlwaysApprove wrappers?
bool hasAlwaysApprove = false;
foreach (var content in message.Contents)
{
if (content is AlwaysApproveToolApprovalResponseContent)
{
hasAlwaysApprove = true;
break;
}
}
if (!hasAlwaysApprove)
{
result.Add(message);
continue;
}
// Walk content items, replacing each AlwaysApprove wrapper with its inner response
// while extracting the standing approval rule into state.
var newContents = new List<AIContent>(message.Contents.Count);
foreach (var content in message.Contents)
{
if (content is AlwaysApproveToolApprovalResponseContent alwaysApprove)
{
// Extract and store the standing approval rule.
if (alwaysApprove.InnerResponse.ToolCall is FunctionCallContent toolCall)
{
if (alwaysApprove.AlwaysApproveTool)
{
AddRuleIfNotExists(state, new ToolApprovalRule { ToolName = toolCall.Name });
}
else if (alwaysApprove.AlwaysApproveToolWithArguments)
{
AddRuleIfNotExists(state, new ToolApprovalRule
{
ToolName = toolCall.Name,
Arguments = SerializeArguments(toolCall.Arguments, jsonSerializerOptions),
});
}
}
// Replace the wrapper with the unwrapped inner response, preserving position.
newContents.Add(alwaysApprove.InnerResponse);
}
else
{
newContents.Add(content);
}
}
// Clone the original message so all metadata is preserved, then replace contents.
var clonedMessage = message.Clone();
clonedMessage.Contents = newContents;
result.Add(clonedMessage);
anyModified = true;
}
// Avoid allocating a new list if nothing was modified.
return anyModified ? result : (messageList as List<ChatMessage> ?? messageList.ToList());
}
/// <summary>
/// Determines whether a tool approval request matches any of the stored rules.
/// </summary>
internal static bool MatchesRule(
ToolApprovalRequestContent request,
IReadOnlyList<ToolApprovalRule> rules,
JsonSerializerOptions jsonSerializerOptions)
{
if (request.ToolCall is not FunctionCallContent functionCall)
{
return false;
}
foreach (var rule in rules)
{
if (!string.Equals(rule.ToolName, functionCall.Name, StringComparison.Ordinal))
{
continue;
}
// Tool-level rule: matches any arguments
if (rule.Arguments is null)
{
return true;
}
// Tool+arguments rule: exact match on all argument values
if (ArgumentsMatch(rule.Arguments, functionCall.Arguments, jsonSerializerOptions))
{
return true;
}
}
return false;
}
/// <summary>
/// Checks whether a <see cref="ToolApprovalRequestContent"/> is approved by any of the configured
/// auto-approval rules (heuristic functions).
/// </summary>
/// <returns>
/// <see langword="true"/> if any auto-approval rule returns <see langword="true"/> for the function call;
/// <see langword="false"/> if no rules are configured, the request is not a function call, or no rule approves it.
/// </returns>
private async ValueTask<bool> MatchesAutoApprovalRuleAsync(ToolApprovalRequestContent request)
{
if (this._autoApprovalRules is not { Length: > 0 })
{
return false;
}
if (request.ToolCall is not FunctionCallContent functionCall)
{
return false;
}
foreach (var rule in this._autoApprovalRules)
{
if (await rule(functionCall).ConfigureAwait(false))
{
return true;
}
}
return false;
}
private static bool ArgumentsMatch(IDictionary<string, string> ruleArguments, IDictionary<string, object?>? callArguments, JsonSerializerOptions jsonSerializerOptions)
{
if (callArguments is null)
{
return ruleArguments.Count == 0;
}
if (ruleArguments.Count != callArguments.Count)
{
return false;
}
foreach (var kvp in ruleArguments)
{
if (!callArguments.TryGetValue(kvp.Key, out var callValue))
{
return false;
}
var serializedCallValue = SerializeArgumentValue(callValue, jsonSerializerOptions);
if (!string.Equals(kvp.Value, serializedCallValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
/// <summary>
/// Serializes function call arguments to a string dictionary for storage and comparison.
/// </summary>
/// <remarks>
/// Always returns a non-null dictionary so that an argument-scoped standing approval
/// (the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveToolWithArguments"/>
/// path) records an exact-arguments rule. A <see langword="null"/> or empty source dictionary
/// yields an empty dictionary, which matches only future no-argument calls. A <see langword="null"/>
/// value is reserved on <see cref="ToolApprovalRule.Arguments"/> for tool-level rules and is never
/// produced here, preventing an exact-arguments approval from widening into a tool-level approval.
/// </remarks>
private static Dictionary<string, string> SerializeArguments(IDictionary<string, object?>? arguments, JsonSerializerOptions jsonSerializerOptions)
{
if (arguments is null || arguments.Count == 0)
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
var serialized = new Dictionary<string, string>(arguments.Count, StringComparer.Ordinal);
foreach (var kvp in arguments)
{
serialized[kvp.Key] = SerializeArgumentValue(kvp.Value, jsonSerializerOptions);
}
return serialized;
}
/// <summary>
/// Serializes a single argument value to its JSON string representation.
/// </summary>
private static string SerializeArgumentValue(object? value, JsonSerializerOptions jsonSerializerOptions)
{
if (value is null)
{
return "null";
}
if (value is JsonElement jsonElement)
{
return jsonElement.GetRawText();
}
return JsonSerializer.Serialize(value, jsonSerializerOptions.GetTypeInfo(value.GetType()));
}
/// <summary>
/// Adds a rule to the state if an equivalent rule does not already exist.
/// </summary>
private static void AddRuleIfNotExists(ToolApprovalState state, ToolApprovalRule newRule)
{
foreach (var existingRule in state.Rules)
{
if (!string.Equals(existingRule.ToolName, newRule.ToolName, StringComparison.Ordinal))
{
continue;
}
if (existingRule.Arguments is null && newRule.Arguments is null)
{
return; // Duplicate tool-level rule
}
if (existingRule.Arguments is not null && newRule.Arguments is not null &&
ArgumentDictionariesEqual(existingRule.Arguments, newRule.Arguments))
{
return; // Duplicate tool+args rule
}
}
state.Rules.Add(newRule);
}
/// <summary>
/// Compares two string dictionaries for equality.
/// </summary>
private static bool ArgumentDictionariesEqual(IDictionary<string, string> a, IDictionary<string, string> b)
{
if (a.Count != b.Count)
{
return false;
}
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var bValue) || !string.Equals(kvp.Value, bValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for adding tool approval middleware to <see cref="AIAgentBuilder"/> instances.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ToolApprovalAgentBuilderExtensions
{
/// <summary>
/// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which tool approval support will be added.</param>
/// <param name="options">
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
/// When <see langword="null"/>, default settings are used.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> with tool approval middleware added, enabling method chaining.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// The <see cref="ToolApprovalAgent"/> middleware intercepts tool approval flows between the caller and the inner agent.
/// When a caller responds with an <see cref="AlwaysApproveToolApprovalResponseContent"/>, the middleware records a standing
/// approval rule so that future matching tool calls are auto-approved without user interaction.
/// </para>
/// </remarks>
public static AIAgentBuilder UseToolApproval(
this AIAgentBuilder builder,
ToolApprovalAgentOptions? options = null)
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, options));
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options for configuring the <see cref="ToolApprovalAgent"/> middleware.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public class ToolApprovalAgentOptions
{
/// <summary>
/// Gets or sets the <see cref="System.Text.Json.JsonSerializerOptions"/> used for serializing argument values
/// when storing rules and for persisting state.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </remarks>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
/// <summary>
/// Gets or sets a collection of heuristic functions that can automatically approve function calls
/// that would otherwise require user approval.
/// </summary>
/// <remarks>
/// <para>
/// Each function receives a <see cref="FunctionCallContent"/> representing the tool call that requires approval
/// and returns a <see cref="ValueTask{Boolean}"/> that resolves to <see langword="true"/> to auto-approve
/// the call, or <see langword="false"/> to continue evaluating the next rule.
/// </para>
/// <para>
/// Auto-approval rules are evaluated after standing rules (derived from prior user approvals) but before
/// prompting the user. Rules are evaluated in order; the first rule returning <see langword="true"/>
/// causes the function call to be auto-approved.
/// </para>
/// </remarks>
public IEnumerable<Func<FunctionCallContent, ValueTask<bool>>>? AutoApprovalRules { get; set; }
}
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods on <see cref="ToolApprovalRequestContent"/> for creating
/// <see cref="AlwaysApproveToolApprovalResponseContent"/> instances that instruct the
/// <see cref="ToolApprovalAgent"/> middleware to record standing approval rules.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ToolApprovalRequestContentExtensions
{
/// <summary>
/// Creates an approved <see cref="AlwaysApproveToolApprovalResponseContent"/> that also
/// instructs the middleware to always approve future calls to the same tool,
/// regardless of the arguments provided.
/// </summary>
/// <param name="request">The tool approval request to respond to.</param>
/// <param name="reason">An optional reason for the approval.</param>
/// <returns>
/// An <see cref="AlwaysApproveToolApprovalResponseContent"/> wrapping an approved
/// <see cref="ToolApprovalResponseContent"/> with the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveTool"/>
/// flag set to <see langword="true"/>.
/// </returns>
public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolResponse(
this ToolApprovalRequestContent request,
string? reason = null)
{
_ = Throw.IfNull(request);
return new AlwaysApproveToolApprovalResponseContent(
request.CreateResponse(approved: true, reason),
alwaysApproveTool: true,
alwaysApproveToolWithArguments: false);
}
/// <summary>
/// Creates an approved <see cref="AlwaysApproveToolApprovalResponseContent"/> that also
/// instructs the middleware to always approve future calls to the same tool
/// with the exact same arguments.
/// </summary>
/// <param name="request">The tool approval request to respond to.</param>
/// <param name="reason">An optional reason for the approval.</param>
/// <returns>
/// An <see cref="AlwaysApproveToolApprovalResponseContent"/> wrapping an approved
/// <see cref="ToolApprovalResponseContent"/> with the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveToolWithArguments"/>
/// flag set to <see langword="true"/>.
/// </returns>
public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolWithArgumentsResponse(
this ToolApprovalRequestContent request,
string? reason = null)
{
_ = Throw.IfNull(request);
return new AlwaysApproveToolApprovalResponseContent(
request.CreateResponse(approved: true, reason),
alwaysApproveTool: false,
alwaysApproveToolWithArguments: true);
}
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a standing approval rule for automatically approving tool calls
/// without requiring explicit user approval each time.
/// </summary>
/// <remarks>
/// <para>
/// A rule can match tool calls in two ways:
/// <list type="bullet">
/// <item><b>Tool-level</b>: When <see cref="Arguments"/> is <see langword="null"/>,
/// all calls to the tool identified by <see cref="ToolName"/> are auto-approved.</item>
/// <item><b>Tool+arguments</b>: When <see cref="Arguments"/> is non-null,
/// only calls to the specified tool with exactly matching argument values are auto-approved.
/// A non-null but <b>empty</b> dictionary matches only calls that supply no arguments; it does
/// not widen into a tool-level rule.</item>
/// </list>
/// </para>
/// <para>
/// <see langword="null"/> is therefore reserved exclusively for tool-level approval. An
/// argument-scoped approval (including one created from a no-argument call) is always stored
/// as a non-null dictionary so it cannot be silently broadened to all invocations of the tool.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class ToolApprovalRule
{
/// <summary>
/// Gets or sets the name of the tool function that this rule applies to.
/// </summary>
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the specific argument values that must match for this rule to apply.
/// When <see langword="null"/>, the rule applies to all invocations of the tool
/// regardless of arguments. A non-null but empty dictionary applies only to
/// invocations that supply no arguments.
/// </summary>
/// <remarks>
/// Argument values are stored as their JSON-serialized string representations
/// for reliable comparison.
/// </remarks>
[JsonPropertyName("arguments")]
public IDictionary<string, string>? Arguments { get; set; }
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the persisted state of standing tool approval rules,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class ToolApprovalState
{
/// <summary>
/// Gets or sets the list of standing approval rules.
/// </summary>
[JsonPropertyName("rules")]
public List<ToolApprovalRule> Rules { get; set; } = new();
/// <summary>
/// Gets or sets the list of collected approval responses (both auto-approved and user-approved)
/// that are pending injection into the next inbound call to the inner agent.
/// </summary>
/// <remarks>
/// <para>
/// Responses are collected during a queue cycle: when the inner agent returns multiple tool approval
/// requests, auto-approved ones and user-approved ones are accumulated here. Once all queued requests
/// are resolved, the collected responses are injected alongside the caller's messages so the inner
/// agent receives all tool responses together.
/// </para>
/// </remarks>
[JsonPropertyName("collectedApprovalResponses")]
public List<ToolApprovalResponseContent> CollectedApprovalResponses { get; set; } = new();
/// <summary>
/// Gets or sets the list of queued tool approval requests that have not yet been
/// presented to the caller.
/// </summary>
/// <remarks>
/// <para>
/// When the inner agent returns multiple unapproved tool approval requests, only the first
/// is returned to the caller. The remaining requests are stored here and presented one at a
/// time on subsequent calls, allowing the caller's "always approve" rules to take effect on
/// later items in the same batch.
/// </para>
/// </remarks>
[JsonPropertyName("queuedApprovalRequests")]
public List<ToolApprovalRequestContent> QueuedApprovalRequests { get; set; } = new();
}