mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Harness Feature branch (#5310)
* .NET: Add a TODO AIContextProvider (#5233) * Add a TODO AIContextProvider * Add unit tests * Address PR comments * Address PR comments * Fix test after removing one tool * .NET: Add a ModeProvider for managing agent modes (#5247) * Add a ModeProvider for managing agent modes * Fix typo * Fix typo * Fix typo * Address PR comments * .NET: Add sample to show how to build a harness (#5268) * Add sample to show how to build a harness * Improve sample * Sample max output tokens and model * Fix encoding * Fix model name in readme * Address PR comments * .NET: Add context window size compaction strategy for harness (#5304) * Add context window size compaction strategy for harness * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * .NET: Add a file memory provider (#5315) * Add a file memory provider * Address PR comments * Fix review comments. * Add additional unit tests * Addressing PR comments. * .NET: Harness: Improve prompts and add FileSystem store (#5365) * Harness: Improve prompts and add FileSystem store * Address PR comments * .NET: Harness: Improve path validation (#5404) * Harness: Improve path validation * Address PR comments * .NET: Add always approve helpers, improve sample and fix bug (#5451) * Add always approve helpers, improve sample and fix bug * Address PR comments * .NET: Make Todo, Mode and FileMemory providers more configurable (#5477) * Make Todo, Mode and FileMemory providers more configurable * Address PR comments. * .NET: Add subagents provider and sample (#5518) * Add subagents provider and sample * Addressing PR comments. * .NET: Harness filememory index plus instructions consistency (#5540) * Add FileMemoryProvider index and improve instruction consistency * Address PR comments. * Address PR comments * Address PR comments. * Apply suggestion from @rogerbarreto Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * .NET: Refactor harness console to be more extensible and easy to understand with better UX (#5573) * Refactor harness console to be more extensible and easy to understand with better UX. * Fix formatting issues. * Allow multiple clarifications in one response * Address PR comments * .NET: Add FileAccessProvdider and concurrency fix for FileMemoryProvider (#5583) * Add FileAccessProvdider and concurrency fix for FileMemoryProvider * Address PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
Roger Barreto
parent
540193ccef
commit
626b418622
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
@@ -69,6 +70,38 @@ internal static partial class AgentJsonUtilities
|
||||
[JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))]
|
||||
[JsonSerializable(typeof(ChatHistoryMemoryProvider.State))]
|
||||
|
||||
// TodoProvider types
|
||||
[JsonSerializable(typeof(TodoState))]
|
||||
[JsonSerializable(typeof(TodoItem))]
|
||||
[JsonSerializable(typeof(TodoItemInput))]
|
||||
[JsonSerializable(typeof(List<int>), TypeInfoPropertyName = "IntList")]
|
||||
[JsonSerializable(typeof(List<TodoItem>), TypeInfoPropertyName = "TodoItemList")]
|
||||
[JsonSerializable(typeof(List<TodoItemInput>), TypeInfoPropertyName = "TodoItemInputList")]
|
||||
|
||||
// AgentModeProvider types
|
||||
[JsonSerializable(typeof(AgentModeState))]
|
||||
|
||||
// ToolApprovalAgent types
|
||||
[JsonSerializable(typeof(ToolApprovalState))]
|
||||
[JsonSerializable(typeof(ToolApprovalRule))]
|
||||
[JsonSerializable(typeof(List<ToolApprovalRule>), TypeInfoPropertyName = "ToolApprovalRuleList")]
|
||||
|
||||
// FileMemoryProvider types
|
||||
[JsonSerializable(typeof(FileMemoryState))]
|
||||
[JsonSerializable(typeof(FileSearchResult))]
|
||||
[JsonSerializable(typeof(List<FileSearchResult>), TypeInfoPropertyName = "FileSearchResultList")]
|
||||
[JsonSerializable(typeof(FileSearchMatch))]
|
||||
[JsonSerializable(typeof(List<FileSearchMatch>), TypeInfoPropertyName = "FileSearchMatchList")]
|
||||
[JsonSerializable(typeof(FileListEntry))]
|
||||
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
|
||||
|
||||
// SubAgentsProvider types
|
||||
[JsonSerializable(typeof(SubAgentState))]
|
||||
[JsonSerializable(typeof(SubAgentRuntimeState))]
|
||||
[JsonSerializable(typeof(SubTaskInfo))]
|
||||
[JsonSerializable(typeof(SubTaskStatus))]
|
||||
[JsonSerializable(typeof(List<SubTaskInfo>), TypeInfoPropertyName = "SubTaskInfoList")]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = enumerator.Current;
|
||||
responseUpdates.Add(update);
|
||||
responseUpdates.Add(update.Clone());
|
||||
|
||||
// If the service returned a real ConversationId on any update, remember that.
|
||||
// Otherwise stamp our sentinel so FICC treats this as service-managed —
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that derives token thresholds from a model's context window size
|
||||
/// and maximum output tokens, applying a two-phase compaction pipeline:
|
||||
/// <list type="number">
|
||||
/// <item><description><b>Tool result eviction</b> (<see cref="ToolResultCompactionStrategy"/>) — collapses old tool call groups
|
||||
/// into concise summaries when the token count exceeds the <see cref="ToolEvictionThreshold"/>.</description></item>
|
||||
/// <item><description><b>Truncation</b> (<see cref="TruncationCompactionStrategy"/>) — removes the oldest non-system message groups
|
||||
/// when the token count exceeds the <see cref="TruncationThreshold"/>.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <b>input budget</b> is defined as <c>maxContextWindowTokens - maxOutputTokens</c>, representing
|
||||
/// the maximum number of tokens available for the conversation input (including system messages, tools, and history).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy is a convenience wrapper around <see cref="PipelineCompactionStrategy"/> that automates
|
||||
/// threshold calculation from model specifications.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class ContextWindowCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default fraction of the input budget at which tool result eviction triggers.
|
||||
/// </summary>
|
||||
public const double DefaultToolEvictionThreshold = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// The default fraction of the input budget at which truncation triggers.
|
||||
/// </summary>
|
||||
public const double DefaultTruncationThreshold = 0.8;
|
||||
|
||||
private readonly PipelineCompactionStrategy _pipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContextWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// </param>
|
||||
/// <param name="toolEvictionThreshold">
|
||||
/// The fraction of the input budget (0.0, 1.0] at which tool result eviction triggers.
|
||||
/// Defaults to <see cref="DefaultToolEvictionThreshold"/> (0.5).
|
||||
/// </param>
|
||||
/// <param name="truncationThreshold">
|
||||
/// The fraction of the input budget (0.0, 1.0] at which truncation triggers.
|
||||
/// Defaults to <see cref="DefaultTruncationThreshold"/> (0.8).
|
||||
/// Must be greater than or equal to <paramref name="toolEvictionThreshold"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>, or
|
||||
/// <paramref name="toolEvictionThreshold"/> or <paramref name="truncationThreshold"/> is not in (0.0, 1.0], or
|
||||
/// <paramref name="truncationThreshold"/> is less than <paramref name="toolEvictionThreshold"/>.
|
||||
/// </exception>
|
||||
public ContextWindowCompactionStrategy(
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
double toolEvictionThreshold = DefaultToolEvictionThreshold,
|
||||
double truncationThreshold = DefaultTruncationThreshold)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
Throw.IfLessThanOrEqual(maxContextWindowTokens, 0);
|
||||
Throw.IfLessThan(maxOutputTokens, 0);
|
||||
Throw.IfGreaterThanOrEqual(maxOutputTokens, maxContextWindowTokens);
|
||||
|
||||
ValidateThreshold(toolEvictionThreshold, nameof(toolEvictionThreshold));
|
||||
ValidateThreshold(truncationThreshold, nameof(truncationThreshold));
|
||||
|
||||
if (truncationThreshold < toolEvictionThreshold)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(truncationThreshold), truncationThreshold,
|
||||
$"Truncation threshold ({truncationThreshold}) must be greater than or equal to tool eviction threshold ({toolEvictionThreshold}).");
|
||||
}
|
||||
|
||||
this.MaxContextWindowTokens = maxContextWindowTokens;
|
||||
this.MaxOutputTokens = maxOutputTokens;
|
||||
this.InputBudgetTokens = maxContextWindowTokens - maxOutputTokens;
|
||||
this.ToolEvictionThreshold = toolEvictionThreshold;
|
||||
this.TruncationThreshold = truncationThreshold;
|
||||
|
||||
int toolEvictionTokens = (int)(this.InputBudgetTokens * toolEvictionThreshold);
|
||||
int truncationTokens = (int)(this.InputBudgetTokens * truncationThreshold);
|
||||
|
||||
this._pipeline = new PipelineCompactionStrategy(
|
||||
new ToolResultCompactionStrategy(
|
||||
trigger: CompactionTriggers.TokensExceed(toolEvictionTokens),
|
||||
minimumPreservedGroups: 2),
|
||||
new TruncationCompactionStrategy(
|
||||
trigger: CompactionTriggers.TokensExceed(truncationTokens),
|
||||
minimumPreservedGroups: 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum context window size in tokens.
|
||||
/// </summary>
|
||||
public int MaxContextWindowTokens { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum output tokens per response.
|
||||
/// </summary>
|
||||
public int MaxOutputTokens { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the computed input budget in tokens (<see cref="MaxContextWindowTokens"/> minus <see cref="MaxOutputTokens"/>).
|
||||
/// </summary>
|
||||
public int InputBudgetTokens { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fraction of the input budget at which tool result eviction triggers.
|
||||
/// </summary>
|
||||
public double ToolEvictionThreshold { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fraction of the input budget at which truncation triggers.
|
||||
/// </summary>
|
||||
public double TruncationThreshold { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
return await this._pipeline.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ValidateThreshold(double value, string paramName)
|
||||
{
|
||||
if (value is <= 0.0 or > 1.0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Threshold must be in the range (0.0, 1.0].");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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>AgentMode_Set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>AgentMode_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 AgentMode_Get tool to check your current operating mode.
|
||||
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
|
||||
|
||||
{available_modes}
|
||||
|
||||
You are currently operating in the {current_mode} mode.
|
||||
""";
|
||||
|
||||
private static readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> s_defaultModes =
|
||||
[
|
||||
new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."),
|
||||
new("execute", "Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice."),
|
||||
];
|
||||
|
||||
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)
|
||||
{
|
||||
// Build list of modes text:
|
||||
var modesListBuilder = new StringBuilder();
|
||||
foreach (var mode in this._modes)
|
||||
{
|
||||
modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}");
|
||||
}
|
||||
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 = "AgentMode_Set",
|
||||
Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
() => state.CurrentMode,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "AgentMode_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,180 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
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 access tools to an agent
|
||||
/// for saving, reading, deleting, listing, and searching 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>SaveFile</c> — Save a file with the given name and content.</description></item>
|
||||
/// <item><description><c>ReadFile</c> — Read the content of a file by name.</description></item>
|
||||
/// <item><description><c>DeleteFile</c> — Delete a file by name.</description></item>
|
||||
/// <item><description><c>ListFiles</c> — List all file names.</description></item>
|
||||
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAccessProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
## File Access
|
||||
You have access to a shared file storage area via the `FileAccess_*` 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.
|
||||
""";
|
||||
|
||||
private readonly AgentFileStore _fileStore;
|
||||
private readonly string _instructions;
|
||||
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="System.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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => [];
|
||||
|
||||
/// <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>
|
||||
/// Save 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 save.</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("Save 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> SaveFileAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = StorePaths.NormalizeRelativePath(fileName);
|
||||
|
||||
if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return $"File '{fileName}' already exists. To replace it, save again with overwrite set to true.";
|
||||
}
|
||||
|
||||
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||
return $"File '{fileName}' saved.";
|
||||
}
|
||||
|
||||
/// <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> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = StorePaths.NormalizeRelativePath(fileName);
|
||||
string? content = await this._fileStore.ReadFileAsync(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> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = StorePaths.NormalizeRelativePath(fileName);
|
||||
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all file names.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A list of file names.</returns>
|
||||
[Description("List all file names.")]
|
||||
private async Task<List<string>> ListFilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(string.Empty, cancellationToken).ConfigureAwait(false);
|
||||
return new List<string>(fileNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search file contents using a regular expression pattern (case-insensitive).
|
||||
/// Optionally 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="filePattern">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 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, snippets, and matching lines with line numbers.")]
|
||||
private async Task<List<FileSearchResult>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
|
||||
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
|
||||
return new List<FileSearchResult>(results);
|
||||
}
|
||||
|
||||
private AITool[] CreateTools()
|
||||
{
|
||||
var serializerOptions = AgentJsonUtilities.DefaultOptions;
|
||||
|
||||
return
|
||||
[
|
||||
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SaveFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ReadFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_DeleteFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ListFiles", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SearchFiles", SerializerOptions = serializerOptions }),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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="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; }
|
||||
}
|
||||
@@ -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 a file entry returned by the <see cref="FileMemoryProvider"/> list files tool,
|
||||
/// containing the file name and an optional description.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileListEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileName")]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <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,425 @@
|
||||
// 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>SaveFile</c> — Save a memory file with the given name, content, and an optional description.</description></item>
|
||||
/// <item><description><c>ReadFile</c> — Read the content of a file by name.</description></item>
|
||||
/// <item><description><c>DeleteFile</c> — Delete a file by name.</description></item>
|
||||
/// <item><description><c>ListFiles</c> — List all files with their descriptions (if available).</description></item>
|
||||
/// <item><description><c>SearchFiles</c> — Search file contents using a regular expression pattern.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
{
|
||||
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 `FileMemory_*` 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 saving a file to help with future discovery.
|
||||
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories.
|
||||
- Keep memories up-to-date by overwriting files when information changes.
|
||||
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
|
||||
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
|
||||
This ensures important data remains accessible across long-running sessions.
|
||||
""";
|
||||
|
||||
private readonly AgentFileStore _fileStore;
|
||||
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.ReadFileAsync(indexPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(indexContent))
|
||||
{
|
||||
aiContext.Messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User,
|
||||
"The following is your memory index — a list of files you have previously saved. " +
|
||||
"You can read any of these files using the FileMemory_ReadFile tool.\n\n" +
|
||||
indexContent),
|
||||
];
|
||||
}
|
||||
|
||||
return aiContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a memory file with the given name and content.
|
||||
/// Overwrites the file if it already exists.
|
||||
/// Include a description for large files to provide a summary that helps with discovery.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to save.</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("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")]
|
||||
private async Task<string> SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsInternalFile(fileName))
|
||||
{
|
||||
throw new ArgumentException("The provided file name is reserved by the system for internal use. Please choose a different file name.", nameof(fileName));
|
||||
}
|
||||
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
|
||||
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove any stale description file when no description is provided.
|
||||
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string result = string.IsNullOrWhiteSpace(description)
|
||||
? $"File '{fileName}' saved."
|
||||
: $"File '{fileName}' saved 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> ReadFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
string? content = await this._fileStore.ReadFileAsync(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> DeleteFileAsync(string fileName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string path = ResolvePath(state.WorkingFolder, fileName);
|
||||
|
||||
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Also delete companion description file if it exists.
|
||||
string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName));
|
||||
await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all memory files with their descriptions (if available). Description files are not shown separately.
|
||||
/// </summary>
|
||||
/// <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). Description files are not shown separately.")]
|
||||
private async Task<List<FileListEntry>> ListFilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
IReadOnlyList<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var descriptionFileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string file in fileNames)
|
||||
{
|
||||
if (file.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptionFileSet.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = new List<FileListEntry>();
|
||||
foreach (string file in fileNames)
|
||||
{
|
||||
if (descriptionFileSet.Contains(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsInternalFile(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? fileDescription = null;
|
||||
string descFileName = GetDescriptionFileName(file);
|
||||
|
||||
if (descriptionFileSet.Contains(descFileName))
|
||||
{
|
||||
string descPath = CombinePaths(state.WorkingFolder, descFileName);
|
||||
fileDescription = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
entries.Add(new FileListEntry { FileName = file, Description = fileDescription });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <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="filePattern">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>> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
|
||||
string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern;
|
||||
IReadOnlyList<FileSearchResult> results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 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.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SaveFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ReadFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_DeleteFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ListFiles", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SearchFiles", 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<string> fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Sort deterministically so the index is stable across runs and platforms.
|
||||
var sortedFiles = fileNames.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("# Memory Index");
|
||||
sb.AppendLine();
|
||||
|
||||
int count = 0;
|
||||
foreach (string file in sortedFiles)
|
||||
{
|
||||
// Skip internal system files.
|
||||
if (IsInternalFile(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count >= MaxIndexEntries)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string? description = null;
|
||||
string descFileName = GetDescriptionFileName(file);
|
||||
string descPath = CombinePaths(state.WorkingFolder, descFileName);
|
||||
description = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
sb.AppendLine($"- **{file}**: {description}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"- **{file}**");
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName);
|
||||
await this._fileStore.WriteFileAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GetDescriptionFileName(string fileName)
|
||||
{
|
||||
int extIndex = fileName.LastIndexOf('.');
|
||||
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);
|
||||
|
||||
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,93 @@
|
||||
// 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 WriteFileAsync(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?> ReadFileAsync(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> DeleteFileAsync(string path, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists files in 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 file names in the specified directory (direct children only).</returns>
|
||||
public abstract Task<IReadOnlyList<string>> ListFilesAsync(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="filePattern">
|
||||
/// An optional glob pattern to filter which files are searched (e.g., <c>"*.md"</c>, <c>"research*"</c>).
|
||||
/// When <see langword="null"/>, all files in the directory are searched.
|
||||
/// Uses standard glob syntax from <see cref="Matcher"/>.
|
||||
/// </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>
|
||||
public abstract Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, 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,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,269 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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 WriteFileAsync(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?> ReadFileAsync(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> DeleteFileAsync(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<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string fullDir = this.ResolveSafeDirectoryPath(directory);
|
||||
|
||||
if (!Directory.Exists(fullDir))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>([]);
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(fullDir)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(name => name is not null)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(files!);
|
||||
}
|
||||
|
||||
/// <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>> SearchFilesAsync(
|
||||
string directory,
|
||||
string regexPattern,
|
||||
string? filePattern = null,
|
||||
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 = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
|
||||
var results = new List<FileSearchResult>();
|
||||
|
||||
foreach (string filePath in Directory.GetFiles(fullDir))
|
||||
{
|
||||
string? fileName = Path.GetFileName(filePath);
|
||||
if (fileName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the optional glob filter on the file name.
|
||||
if (!StorePaths.MatchesGlob(fileName, 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 = fileName,
|
||||
Snippet = firstSnippet!,
|
||||
MatchingLines = matchingLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <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 or rooted paths.
|
||||
/// </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));
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/// <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,160 @@
|
||||
// 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 WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = StorePaths.NormalizeRelativePath(path);
|
||||
this._files[path] = content;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<string?> ReadFileAsync(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> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = StorePaths.NormalizeRelativePath(path);
|
||||
return Task.FromResult(this._files.TryRemove(path, out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
|
||||
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
prefix += "/";
|
||||
}
|
||||
|
||||
var files = this._files.Keys
|
||||
.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k.Substring(prefix.Length))
|
||||
.Where(k => k.IndexOf("/", StringComparison.Ordinal) < 0)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(files);
|
||||
}
|
||||
|
||||
/// <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>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, 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 = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : 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;
|
||||
}
|
||||
|
||||
// Exclude files in subdirectories (direct children only).
|
||||
string relativeName = kvp.Key.Substring(prefix.Length);
|
||||
if (relativeName.IndexOf("/", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the optional glob filter on the file name.
|
||||
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,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 sub-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="SubTaskStatus.Lost"/> by <see cref="SubAgentsProvider"/>.
|
||||
/// </remarks>
|
||||
internal sealed class SubAgentRuntimeState
|
||||
{
|
||||
/// <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 sub-agent <see cref="AgentSession"/> instances,
|
||||
/// needed for <c>ContinueTask</c>.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Dictionary<int, AgentSession> SubTaskSessions { 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 sub-tasks managed by the <see cref="SubAgentsProvider"/>,
|
||||
/// stored in the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class SubAgentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the next ID to assign to a new sub-task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextTaskId")]
|
||||
public int NextTaskId { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of sub-task metadata entries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tasks")]
|
||||
public List<SubTaskInfo> Tasks { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// 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 sub-agents asynchronously.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="SubAgentsProvider"/> allows a parent agent to start sub-tasks on child agents,
|
||||
/// wait for their completion, and retrieve results. Each sub-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>SubAgents_StartTask</c> — Start a sub-task on a named agent with text input. Returns the task ID.</description></item>
|
||||
/// <item><description><c>SubAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
|
||||
/// <item><description><c>SubAgents_GetTaskResults</c> — Retrieve the text output of a completed sub-task.</description></item>
|
||||
/// <item><description><c>SubAgents_GetAllTasks</c> — List all sub-tasks with their IDs, statuses, descriptions, and agent names.</description></item>
|
||||
/// <item><description><c>SubAgents_ContinueTask</c> — Send follow-up input to a completed sub-task's session to resume work.</description></item>
|
||||
/// <item><description><c>SubAgents_ClearCompletedTask</c> — Remove a completed sub-task and release its session to free memory.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SubAgentsProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
## SubAgents
|
||||
You have access to sub-agents that can perform work on your behalf.
|
||||
|
||||
- Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
|
||||
- Creating a sub task does not block, and sub-tasks run concurrently.
|
||||
- Important: Always wait for outstanding tasks to finish before you finish processing.
|
||||
- Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
|
||||
|
||||
{sub_agents}
|
||||
""";
|
||||
|
||||
private readonly Dictionary<string, AIAgent> _agents;
|
||||
private readonly ProviderSessionState<SubAgentState> _sessionState;
|
||||
private readonly ProviderSessionState<SubAgentRuntimeState> _runtimeSessionState;
|
||||
private readonly string _instructions;
|
||||
private IReadOnlyList<string>? _stateKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubAgentsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agents">The collection of sub-agents available for delegation.</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 SubAgentsProvider(IEnumerable<AIAgent> agents, SubAgentsProviderOptions? 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("{sub_agents}", agentListText);
|
||||
|
||||
this._sessionState = new ProviderSessionState<SubAgentState>(
|
||||
_ => new SubAgentState(),
|
||||
this.GetType().Name,
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
this._runtimeSessionState = new ProviderSessionState<SubAgentRuntimeState>(
|
||||
_ => new SubAgentRuntimeState(),
|
||||
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)
|
||||
{
|
||||
SubAgentState state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._instructions,
|
||||
Tools = this.CreateTools(state, runtimeState, context.Session),
|
||||
});
|
||||
}
|
||||
|
||||
/// <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 sub-agents must have a non-empty Name.", nameof(agents));
|
||||
}
|
||||
|
||||
if (dict.ContainsKey(agent.Name))
|
||||
{
|
||||
throw new ArgumentException($"Duplicate sub-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 sub-agent must be provided.", nameof(agents));
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the default text listing available sub-agents and their descriptions.
|
||||
/// </summary>
|
||||
private static string BuildDefaultAgentListText(IReadOnlyDictionary<string, AIAgent> agents)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Available sub-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(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (SubTaskInfo task in state.Tasks)
|
||||
{
|
||||
if (task.Status != SubTaskStatus.Running)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
|
||||
{
|
||||
// In-flight reference lost (e.g., after restart/deserialization).
|
||||
task.Status = SubTaskStatus.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 SubTaskInfo.
|
||||
/// </summary>
|
||||
private static void FinalizeTask(SubTaskInfo taskInfo, Task<AgentResponse> completedTask, SubAgentRuntimeState runtimeState)
|
||||
{
|
||||
if (completedTask.Status == TaskStatus.RanToCompletion)
|
||||
{
|
||||
taskInfo.Status = SubTaskStatus.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 = SubTaskStatus.Failed;
|
||||
taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error";
|
||||
}
|
||||
else if (completedTask.IsCanceled)
|
||||
{
|
||||
taskInfo.Status = SubTaskStatus.Failed;
|
||||
taskInfo.ErrorText = "Task was canceled.";
|
||||
}
|
||||
|
||||
runtimeState.InFlightTasks.Remove(taskInfo.Id);
|
||||
}
|
||||
|
||||
private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
|
||||
{
|
||||
var serializerOptions = AgentJsonUtilities.DefaultOptions;
|
||||
|
||||
return
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
async (
|
||||
[Description("The name of the sub agent to delegate the task to.")] string agentName,
|
||||
[Description("The request to pass to the sub 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 sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
|
||||
}
|
||||
|
||||
int taskId = state.NextTaskId++;
|
||||
var taskInfo = new SubTaskInfo
|
||||
{
|
||||
Id = taskId,
|
||||
AgentName = agentName,
|
||||
Description = description,
|
||||
Status = SubTaskStatus.Running,
|
||||
};
|
||||
state.Tasks.Add(taskInfo);
|
||||
|
||||
// Create a dedicated session for this sub-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 sub-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.SubTaskSessions[taskId] = subSession;
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return $"Sub-task {taskId} started on agent '{agentName}'.";
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "SubAgents_StartTask",
|
||||
Description = "Start a sub-task on a named sub-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.
|
||||
SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.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.
|
||||
SubTaskInfo? 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 = "SubAgents_WaitForFirstCompletion",
|
||||
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
(int taskId) =>
|
||||
{
|
||||
this.TryRefreshTaskState(state, runtimeState, session);
|
||||
|
||||
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
if (taskInfo is null)
|
||||
{
|
||||
return $"Error: No task found with ID {taskId}.";
|
||||
}
|
||||
|
||||
return taskInfo.Status switch
|
||||
{
|
||||
SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
|
||||
SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
|
||||
SubTaskStatus.Lost => "Task state was lost (reference unavailable).",
|
||||
SubTaskStatus.Running => $"Task {taskId} is still running.",
|
||||
_ => $"Task {taskId} has status: {taskInfo.Status}.",
|
||||
};
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "SubAgents_GetTaskResults",
|
||||
Description = "Get the text output of a sub-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 (SubTaskInfo 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 = "SubAgents_GetAllTasks",
|
||||
Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
(int taskId, string text) =>
|
||||
{
|
||||
this.TryRefreshTaskState(state, runtimeState, session);
|
||||
|
||||
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
if (taskInfo is null)
|
||||
{
|
||||
return $"Error: No task found with ID {taskId}.";
|
||||
}
|
||||
|
||||
if (taskInfo.Status == SubTaskStatus.Lost)
|
||||
{
|
||||
return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead.";
|
||||
}
|
||||
|
||||
if (taskInfo.Status == SubTaskStatus.Running)
|
||||
{
|
||||
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
|
||||
}
|
||||
|
||||
if (!this._agents.TryGetValue(taskInfo.AgentName, out AIAgent? agent))
|
||||
{
|
||||
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
|
||||
}
|
||||
|
||||
if (!runtimeState.SubTaskSessions.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 = SubTaskStatus.Running;
|
||||
taskInfo.ResultText = null;
|
||||
taskInfo.ErrorText = null;
|
||||
|
||||
// Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask 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 = "SubAgents_ContinueTask",
|
||||
Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
(int taskId) =>
|
||||
{
|
||||
this.TryRefreshTaskState(state, runtimeState, session);
|
||||
|
||||
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
if (taskInfo is null)
|
||||
{
|
||||
return $"Error: No task found with ID {taskId}.";
|
||||
}
|
||||
|
||||
if (taskInfo.Status == SubTaskStatus.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.SubTaskSessions.Remove(taskId);
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return $"Task {taskId} cleared.";
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "SubAgents_ClearCompletedTask",
|
||||
Description = "Remove a completed or failed sub-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="SubAgentsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SubAgentsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use the <c>{sub_agents}</c> placeholder to allow the provider to inject
|
||||
/// the formatted list of available sub agents.
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// When <see langword="null"/> (the default), the provider uses built-in instructions
|
||||
/// that guide the agent on how to use the sub-agent tools.
|
||||
/// 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 sub-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 sub-task managed by the <see cref="SubAgentsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SubTaskInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this sub-task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the agent that is executing this sub-task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("agentName")]
|
||||
public string AgentName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a description of what this sub-task is doing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current status of this sub-task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public SubTaskStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text result of the sub-task, populated when the task completes successfully.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resultText")]
|
||||
public string? ResultText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message if the sub-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 sub-task managed by the <see cref="SubAgentsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public enum SubTaskStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The sub-task is currently running.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// The sub-task completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// The sub-task failed with an error.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// The sub-task's in-flight reference was lost (e.g., after a restart),
|
||||
/// and its final state cannot be determined.
|
||||
/// </summary>
|
||||
Lost,
|
||||
}
|
||||
@@ -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,209 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
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>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class TodoProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
## Todo Items
|
||||
|
||||
You have access to a todo list for tracking work items.
|
||||
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
|
||||
Ask questions from the user where clarification is needed to create effective todos.
|
||||
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant 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 or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use TodoList_Complete to mark items as done when finished (supports one or many at once).
|
||||
- Use TodoList_GetRemaining to check what work is still pending.
|
||||
- Use TodoList_GetAll to review the full list including completed items.
|
||||
- Use TodoList_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 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._sessionState = new ProviderSessionState<TodoState>(
|
||||
_ => new TodoState(),
|
||||
this.GetType().Name,
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all todo items from the session state.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session to read todos from.</param>
|
||||
/// <returns>A read-only list of all todo items.</returns>
|
||||
public IReadOnlyList<TodoItem> GetAllTodos(AgentSession? session)
|
||||
{
|
||||
return this._sessionState.GetOrInitializeState(session).Items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining (incomplete) todo items from the session state.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session to read todos from.</param>
|
||||
/// <returns>A list of incomplete todo items.</returns>
|
||||
public List<TodoItem> GetRemainingTodos(AgentSession? session)
|
||||
{
|
||||
return this._sessionState.GetOrInitializeState(session).Items.Where(t => !t.IsComplete).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
TodoState state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._instructions,
|
||||
Tools = this.CreateTools(state, context.Session),
|
||||
});
|
||||
}
|
||||
|
||||
// Note: These tool delegates mutate shared session state without synchronization.
|
||||
// This is safe because FunctionInvokingChatClient serializes tool calls within a single run.
|
||||
private AITool[] CreateTools(TodoState state, AgentSession? session)
|
||||
{
|
||||
var serializerOptions = AgentJsonUtilities.DefaultOptions;
|
||||
|
||||
return
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(List<TodoItemInput> todos) =>
|
||||
{
|
||||
var created = new List<TodoItem>();
|
||||
foreach (var input in todos)
|
||||
{
|
||||
var item = new TodoItem
|
||||
{
|
||||
Id = state.NextId++,
|
||||
Title = input.Title,
|
||||
Description = input.Description,
|
||||
};
|
||||
state.Items.Add(item);
|
||||
created.Add(item);
|
||||
}
|
||||
|
||||
this._sessionState.SaveState(session, state);
|
||||
return created;
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_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(
|
||||
(List<int> ids) =>
|
||||
{
|
||||
var idSet = new HashSet<int>(ids);
|
||||
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;
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Complete",
|
||||
Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
(List<int> ids) =>
|
||||
{
|
||||
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;
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_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(
|
||||
() => state.Items.Where(t => !t.IsComplete).ToList(),
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetRemaining",
|
||||
Description = "Retrieve the list of incomplete todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
AIFunctionFactory.Create(
|
||||
() => state.Items,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetAll",
|
||||
Description = "Retrieve the full list of todo items, both complete and incomplete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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="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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+67
@@ -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,781 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent to delegate to.</param>
|
||||
/// <param name="jsonSerializerOptions">
|
||||
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
|
||||
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
|
||||
public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._sessionState = new ProviderSessionState<ToolApprovalState>(
|
||||
_ => new ToolApprovalState(),
|
||||
"toolApprovalState",
|
||||
this._jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
|
||||
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
|
||||
|
||||
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 = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session);
|
||||
|
||||
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 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
|
||||
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
|
||||
|
||||
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.
|
||||
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
|
||||
{
|
||||
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-approves any that now match.
|
||||
/// </summary>
|
||||
private void DrainAutoApprovableFromQueue(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 (ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)
|
||||
PrepareInboundMessages(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.
|
||||
this.DrainAutoApprovableFromQueue(state);
|
||||
|
||||
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 bool ProcessAndQueueOutboundApprovalRequests(
|
||||
IList<ChatMessage> responseMessages,
|
||||
ToolApprovalState state,
|
||||
AgentSession? session)
|
||||
{
|
||||
// Pass 1: Scan all response messages and classify each approval request as
|
||||
// auto-approved (matches a standing rule) or unapproved (needs caller decision).
|
||||
var autoApproved = new List<ToolApprovalRequestContent>();
|
||||
var unapproved = new List<ToolApprovalRequestContent>();
|
||||
|
||||
foreach (var message in responseMessages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent tarc)
|
||||
{
|
||||
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
|
||||
{
|
||||
autoApproved.Add(tarc);
|
||||
}
|
||||
else
|
||||
{
|
||||
unapproved.Add(tarc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
|
||||
if (autoApproved.Count == 0 && unapproved.Count <= 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store auto-approved responses for later injection into the inner agent.
|
||||
foreach (var tarc in autoApproved)
|
||||
{
|
||||
state.CollectedApprovalResponses.Add(
|
||||
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
|
||||
}
|
||||
|
||||
// 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.
|
||||
var toRemove = new HashSet<ToolApprovalRequestContent>(autoApproved);
|
||||
if (unapproved.Count > 1)
|
||||
{
|
||||
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>
|
||||
/// Compares stored rule arguments against actual function call arguments for an exact match.
|
||||
/// </summary>
|
||||
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>
|
||||
private static Dictionary<string, string>? SerializeArguments(IDictionary<string, object?>? arguments, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
if (arguments is null || arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
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="jsonSerializerOptions">
|
||||
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
|
||||
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is 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,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions));
|
||||
}
|
||||
+65
@@ -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,45 @@
|
||||
// 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.</item>
|
||||
/// </list>
|
||||
/// </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.
|
||||
/// </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();
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
|
||||
Reference in New Issue
Block a user