Merge branch 'copilot/add-preconfigured-compaction-strategy' of https://github.com/microsoft/agent-framework into copilot/add-preconfigured-compaction-strategy

This commit is contained in:
Chris Rickman
2026-03-27 15:50:32 -07:00
Unverified
264 changed files with 15528 additions and 7081 deletions
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
namespace Azure.AI.Extensions.OpenAI;
/// <summary>
/// Provides extension methods for <see cref="ProjectResponsesClient"/>
/// to simplify the creation of AI agents that work with Azure AI services.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class ProjectResponsesClientExtensions
{
/// <summary>
/// Gets an <see cref="IChatClient"/> for use with this <see cref="ProjectResponsesClient"/> that does not store responses for later retrieval.
/// </summary>
/// <remarks>
/// This corresponds to setting the "store" property in the JSON representation to false.
/// </remarks>
/// <param name="responseClient">The client.</param>
/// <param name="deploymentName">Optional deployment name (model) to use for requests.</param>
/// <param name="includeReasoningEncryptedContent">
/// Includes an encrypted version of reasoning tokens in reasoning item outputs.
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly
/// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program).
/// Defaults to <see langword="true"/>.
/// </param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ProjectResponsesClient"/> that does not store responses for later retrieval.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ProjectResponsesClient responseClient, string? deploymentName = null, bool includeReasoningEncryptedContent = true)
{
return Throw.IfNull(responseClient)
.AsIChatClient(deploymentName)
.AsBuilder()
.ConfigureOptions(x =>
{
var previousFactory = x.RawRepresentationFactory;
x.RawRepresentationFactory = state =>
{
var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions();
responseOptions.StoredOutputEnabled = false;
if (includeReasoningEncryptedContent &&
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
{
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
}
return responseOptions;
};
})
.Build();
}
}
@@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
public virtual ChatMessage ToChatMessage()
{
var role = new ChatRole(this.Role);
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
return new(role, this.Content.Text);
}
else if (this.Content.IsContents)
{
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
return new ChatMessage(ChatRole.User, aiContents!);
return new ChatMessage(role, aiContents!);
}
throw new InvalidOperationException("MessageContent has no value");
@@ -165,9 +167,11 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
public override ChatMessage ToChatMessage()
{
var role = new ChatRole(this.Role);
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
return new(role, this.Content.Text);
}
throw new InvalidOperationException("FunctionMessage Content must be text");
@@ -105,7 +105,7 @@ public static class OpenAIResponseClientExtensions
/// This corresponds to setting the "store" property in the JSON representation to false.
/// </remarks>
/// <param name="responseClient">The client.</param>
/// <param name="model">Optional default model ID to use for requests. Required when using a plain <see cref="ResponsesClient"/> (not via Azure OpenAI).</param>
/// <param name="model">Optional default model ID to use for requests.</param>
/// <param name="includeReasoningEncryptedContent">
/// Includes an encrypted version of reasoning tokens in reasoning item outputs.
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly
@@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder
return builder.Build();
}
/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <summary>Creates a new <see cref="HandoffWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <param name="initialAgent">The agent that will receive inputs provided to the workflow.</param>
/// <returns>The builder for creating a workflow based on handoffs.</returns>
/// <remarks>
@@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
@@ -106,8 +106,7 @@ internal sealed class StateManager
if (typeof(T) == typeof(object))
{
// Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc.
// Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369
//throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants.");
throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants.");
}
Throw.IfNullOrEmpty(key);
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -8,22 +9,42 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <inheritdoc/>
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
{
}
/// <inheritdoc/>
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
{
}
/// <summary>
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
public sealed class HandoffsWorkflowBuilder
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
internal const string FunctionPrefix = "handoff_to_";
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
/// where `&lt;agent_id&gt;` is the ID of the target agent to hand off to.
/// </summary>
public const string FunctionPrefix = "handoff_to_";
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
@@ -47,14 +68,41 @@ public sealed class HandoffsWorkflowBuilder
""";
/// <summary>
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
/// perform them.
/// Sets instructions to provide to each agent that has handoffs about how and when to perform them.
/// </summary>
/// <remarks>
/// In the vast majority of cases, the <see cref="DefaultHandoffInstructions"/> will be sufficient, and there will be no need to customize.
/// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see
/// <see cref="FunctionPrefix"/> constant.
/// </remarks>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
public TBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
/// </summary>
/// <param name="emitAgentResponseUpdateEvents"></param>
/// <returns></returns>
public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether aggregated agent response events should be emitted during execution.
/// </summary>
/// <param name="emitAgentResponseEvents"></param>
/// <returns></returns>
public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
return (TBuilder)this;
}
/// <summary>
@@ -62,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
/// </summary>
/// <param name="behavior">The filtering behavior to apply.</param>
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
return this;
return (TBuilder)this;
}
/// <summary>
/// Configures the workflow so that subsequent user turns route directly back to the specialist agent
/// that handled the previous turn, rather than always routing through the initial (coordinator) agent.
/// </summary>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public TBuilder EnableReturnToPrevious()
{
this._returnToPrevious = true;
return (TBuilder)this;
}
/// <summary>
@@ -75,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
public TBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -90,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(from, target);
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -103,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -118,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(source, to, handoffReason);
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -131,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -161,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
}
return this;
return (TBuilder)this;
}
/// <summary>
@@ -171,17 +230,40 @@ public sealed class HandoffsWorkflowBuilder
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new();
HandoffsEndExecutor end = new();
HandoffsStartExecutor start = new(this._returnToPrevious);
HandoffsEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
// Create an AgentExecutor for each agent.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Connect the start executor to the initial agent.
builder.AddEdge(start, executors[this._initialAgent.Id]);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
{
string initialAgentId = this._initialAgent.Id;
builder.AddSwitch(start, sb =>
{
foreach (var agent in this._allAgents)
{
if (agent.Id != initialAgentId)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
}
}
sb.WithDefault(executors[initialAgentId]);
});
}
else
{
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
@@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
internal static class TurnExtensions
{
public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting)
=> token.EmitEvents ?? agentSetting ?? false;
public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
=> turnTokenSetting ?? agentSetting ?? false;
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
@@ -104,9 +113,6 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
}, context, cancellationToken);
}
public bool ShouldEmitStreamingEvents(bool? emitEvents)
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
@@ -175,7 +181,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
}
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
=> this.ContinueTurnAsync(messages,
context,
TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
cancellationToken);
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
@@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
this.EmitAgentResponseEvents = emitAgentResponseEvents;
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
public bool EmitAgentResponseEvents { get; set; }
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
@@ -36,7 +42,7 @@ internal sealed class HandoffMessagesFilter
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
@@ -167,6 +173,7 @@ internal sealed class HandoffAgentExecutor(
private readonly AIAgent _agent = agent;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
@@ -193,9 +200,10 @@ internal sealed class HandoffAgentExecutor(
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
@@ -250,16 +258,27 @@ internal sealed class HandoffAgentExecutor(
}
}
allMessages.AddRange(updates.ToAgentResponse().Messages);
AgentResponse agentResponse = updates.ToAgentResponse();
if (options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
}
allMessages.AddRange(agentResponse.Messages);
roleChanges.ResetUserToAssistantForChangedRoles();
return new(message.TurnToken, requestedHandoff, allMessages);
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
? targetAgentId
: this._agent.Id;
return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
if (message.TurnToken.EmitEvents is true)
if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
@@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
List<ChatMessage> Messages);
List<ChatMessage> Messages,
string? CurrentAgentId = null);
@@ -1,20 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
if (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
}
@@ -7,8 +7,14 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class HandoffConstants
{
internal const string CurrentAgentTrackerKey = "LastAgentId";
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -22,7 +28,25 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
{
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
return currentAgentId;
},
HandoffConstants.CurrentAgentTrackerKey,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken);
}
HandoffState handoff = new(new(emitEvents), null, messages);
return context.SendMessageAsync(handoff, cancellationToken);
}
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -43,7 +43,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
=> this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages);
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(this._sessionState.GetOrInitializeState(context.Session).Messages);
=> new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly());
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
@@ -62,6 +62,12 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
}
}
public IEnumerable<ChatMessage> GetAllMessages(AgentSession session)
{
var state = this._sessionState.GetOrInitializeState(session);
return state.Messages.AsReadOnly();
}
public void UpdateBookmark(AgentSession session)
{
var state = this._sessionState.GetOrInitializeState(session);
@@ -119,13 +119,17 @@ internal sealed class WorkflowHostAgent : AIAgent
MessageMerger merger = new();
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
{
merger.AddUpdate(update);
}
return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
return response;
}
protected override async
@@ -138,11 +142,18 @@ internal sealed class WorkflowHostAgent : AIAgent
await this.ValidateWorkflowAsync().ConfigureAwait(false);
WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false);
MessageMerger merger = new();
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
{
merger.AddUpdate(update);
yield return update;
}
AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
}
}
@@ -131,7 +131,7 @@ internal sealed class WorkflowSession : AgentSession
{
Throw.IfNullOrEmpty(parts);
AgentResponseUpdate update = new(ChatRole.Assistant, parts)
return new(ChatRole.Assistant, parts)
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
@@ -139,27 +139,19 @@ internal sealed class WorkflowSession : AgentSession
ResponseId = responseId,
RawRepresentation = raw
};
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
return update;
}
public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message)
{
Throw.IfNull(message);
AgentResponseUpdate update = new(message.Role, message.Contents)
return new(message.Role, message.Contents)
{
CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow,
MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"),
ResponseId = responseId,
RawRepresentation = raw
};
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
return update;
}
private async ValueTask<ResumeRunResult> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
@@ -328,111 +320,104 @@ internal sealed class WorkflowSession : AgentSession
IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
try
{
this.LastResponseId = Guid.NewGuid().ToString("N");
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
this.LastResponseId = Guid.NewGuid().ToString("N");
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
ResumeRunResult resumeResult =
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
ResumeRunResult resumeResult =
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
#pragma warning disable CA2007 // Analyzer misfiring.
await using StreamingRun run = resumeResult.Run;
await using StreamingRun run = resumeResult.Run;
#pragma warning restore CA2007
ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
// Send a TurnToken to the start executor unless the only activity is an external
// response directed at the start executor itself (which self-emits a TurnToken via
// ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
// TurnTokens after processing responses, so the session must always provide one.
bool shouldSendTurnToken =
!dispatchInfo.HasMatchedExternalResponses
|| !dispatchInfo.HasMatchedResponseForStartExecutor;
if (shouldSendTurnToken)
{
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
}
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
// Send a TurnToken to the start executor unless the only activity is an external
// response directed at the start executor itself (which self-emits a TurnToken via
// ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
// TurnTokens after processing responses, so the session must always provide one.
bool shouldSendTurnToken =
!dispatchInfo.HasMatchedExternalResponses
|| !dispatchInfo.HasMatchedResponseForStartExecutor;
if (shouldSendTurnToken)
{
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
}
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
{
switch (evt)
{
case AgentResponseUpdateEvent agentUpdate:
yield return agentUpdate.Update;
break;
case RequestInfoEvent requestInfo:
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
// Track the pending request so we can convert incoming responses back to ExternalResponse.
// External callers respond using the workflow-facing request ID, which is always RequestId.
this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request);
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent);
yield return update;
break;
case WorkflowErrorEvent workflowError:
Exception? exception = workflowError.Exception;
if (exception is TargetInvocationException tie && tie.InnerException != null)
{
exception = tie.InnerException;
}
if (exception != null)
{
string message = this._includeExceptionDetails
? exception.Message
: "An error occurred while executing the workflow.";
ErrorContent errorContent = new(message);
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
}
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
case WorkflowOutputEvent output:
IEnumerable<ChatMessage>? updateMessages = output.Data switch
{
IEnumerable<ChatMessage> chatMessages => chatMessages,
ChatMessage chatMessage => [chatMessage],
_ => null
};
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
{
goto default;
}
foreach (ChatMessage message in updateMessages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
break;
default:
// Emit all other workflow events for observability (DevUI, logging, etc.)
yield return new AgentResponseUpdate(ChatRole.Assistant, [])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
ResponseId = this.LastResponseId,
RawRepresentation = evt
};
break;
}
}
}
finally
{
// Do we want to try to undo the step, and not update the bookmark?
this.ChatHistoryProvider.UpdateBookmark(this);
switch (evt)
{
case AgentResponseUpdateEvent agentUpdate:
yield return agentUpdate.Update;
break;
case RequestInfoEvent requestInfo:
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
// Track the pending request so we can convert incoming responses back to ExternalResponse.
// External callers respond using the workflow-facing request ID, which is always RequestId.
this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request);
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent);
yield return update;
break;
case WorkflowErrorEvent workflowError:
Exception? exception = workflowError.Exception;
if (exception is TargetInvocationException tie && tie.InnerException != null)
{
exception = tie.InnerException;
}
if (exception != null)
{
string message = this._includeExceptionDetails
? exception.Message
: "An error occurred while executing the workflow.";
ErrorContent errorContent = new(message);
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
}
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
case WorkflowOutputEvent output:
IEnumerable<ChatMessage>? updateMessages = output.Data switch
{
IEnumerable<ChatMessage> chatMessages => chatMessages,
ChatMessage chatMessage => [chatMessage],
_ => null
};
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
{
goto default;
}
foreach (ChatMessage message in updateMessages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
break;
default:
// Emit all other workflow events for observability (DevUI, logging, etc.)
yield return new AgentResponseUpdate(ChatRole.Assistant, [])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
ResponseId = this.LastResponseId,
RawRepresentation = evt
};
break;
}
}
}
@@ -788,6 +788,13 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedSession.ConversationId;
}
// When per-service-call persistence is active, set a sentinel conversation ID so that
// FunctionInvokingChatClient treats locally-persisted history the same as service-managed
// history. This prevents it from adding duplicate FunctionCallContent messages into the
// request when processing approval responses — the loaded history already contains them.
// ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client.
chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions);
// Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible.
List<ChatMessage> messagesList = inputMessagesForChatClient as List<ChatMessage> ?? inputMessagesForChatClient.ToList();
@@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
/// <summary>
/// Sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/> sentinel on
/// <paramref name="chatOptions"/> when per-service-call persistence is active and no real
/// conversation ID is present.
/// </summary>
/// <returns>
/// The (possibly new) <see cref="ChatOptions"/> with the sentinel set, or the original
/// <paramref name="chatOptions"/> if no sentinel is needed.
/// </returns>
private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
{
if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
{
chatOptions ??= new ChatOptions();
chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
}
return chatOptions;
}
/// <summary>
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
@@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
/// </summary>
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
/// <summary>
/// A sentinel value set on <see cref="ChatOptions.ConversationId"/> by <see cref="ChatClientAgent"/>
/// when per-service-call persistence is active and no real conversation ID exists.
/// </summary>
/// <remarks>
/// <para>
/// This signals to <see cref="FunctionInvokingChatClient"/> that the chat history is being managed
/// externally (by this decorator), which prevents it from adding duplicate <see cref="FunctionCallContent"/>
/// messages into the request during approval-response processing. Without this sentinel,
/// <see cref="FunctionInvokingChatClient"/> would reconstruct function-call messages from approval
/// responses and append them to the original messages — but the loaded history already contains
/// those same function calls, causing duplicate tool-call entries that the model rejects.
/// </para>
/// <para>
/// This decorator strips the sentinel before forwarding requests to the inner client, so the
/// underlying model never sees it.
/// </para>
/// </remarks>
internal const string LocalHistoryConversationId = "_agent_local_history";
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
/// </summary>
@@ -87,6 +107,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
ChatResponse response;
try
@@ -130,6 +151,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
List<ChatResponseUpdate> responseUpdates = [];
@@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
}
}
}
/// <summary>
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
/// returns a clone with the conversation ID cleared so the inner client never sees it.
/// Otherwise returns the original <paramref name="options"/> unchanged.
/// </summary>
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
{
if (options?.ConversationId == LocalHistoryConversationId)
{
options = options.Clone();
options.ConversationId = null;
}
return options;
}
}
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for all agent skills.
/// </summary>
/// <remarks>
/// <para>
/// A skill represents a domain-specific capability with instructions, resources, and scripts.
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed).
/// </para>
/// <para>
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkill
{
/// <summary>
/// Gets the frontmatter metadata for this skill.
/// </summary>
/// <remarks>
/// Contains the L1 discovery metadata (name, description, license, compatibility, etc.)
/// as defined by the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// </remarks>
public abstract AgentSkillFrontmatter Frontmatter { get; }
/// <summary>
/// Gets the full skill content.
/// </summary>
/// <remarks>
/// For file-based skills this is the raw SKILL.md file content.
/// </remarks>
public abstract string Content { get; }
/// <summary>
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
/// </summary>
/// <remarks>
/// The default implementation returns <see langword="null"/>.
/// Override this property in derived classes to provide skill-specific resources.
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
/// <summary>
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
/// </summary>
/// <remarks>
/// The default implementation returns <see langword="null"/>.
/// Override this property in derived classes to provide skill-specific scripts.
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
}
@@ -0,0 +1,196 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the YAML frontmatter metadata parsed from a SKILL.md file.
/// </summary>
/// <remarks>
/// <para>
/// Frontmatter is the L1 (discovery) layer of the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// It contains the minimal metadata needed to advertise a skill in the system prompt
/// without loading the full skill content.
/// </para>
/// <para>
/// The constructor validates the name and description against specification rules
/// and throws <see cref="ArgumentException"/> if either value is invalid.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillFrontmatter
{
/// <summary>
/// Maximum allowed length for the skill name.
/// </summary>
internal const int MaxNameLength = 64;
/// <summary>
/// Maximum allowed length for the skill description.
/// </summary>
internal const int MaxDescriptionLength = 1024;
/// <summary>
/// Maximum allowed length for the compatibility field.
/// </summary>
internal const int MaxCompatibilityLength = 500;
// Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter):
// lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens.
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
private string? _compatibility;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillFrontmatter"/> class.
/// </summary>
/// <param name="name">Skill name in kebab-case.</param>
/// <param name="description">Skill description for discovery.</param>
/// <param name="compatibility">Optional compatibility information (max 500 chars).</param>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="name"/>, <paramref name="description"/>, or <paramref name="compatibility"/> violates the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see> rules.
/// </exception>
public AgentSkillFrontmatter(string name, string description, string? compatibility = null)
{
if (!ValidateName(name, out string? reason) ||
!ValidateDescription(description, out reason) ||
!ValidateCompatibility(compatibility, out reason))
{
throw new ArgumentException(reason);
}
this.Name = name;
this.Description = description;
this._compatibility = compatibility;
}
/// <summary>
/// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the skill description. Used for discovery in the system prompt.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets or sets an optional license name or reference.
/// </summary>
public string? License { get; set; }
/// <summary>
/// Gets or sets optional compatibility information (max 500 chars).
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown when the value exceeds <see cref="MaxCompatibilityLength"/> characters.
/// </exception>
public string? Compatibility
{
get => this._compatibility;
set
{
if (!ValidateCompatibility(value, out string? reason))
{
throw new ArgumentException(reason);
}
this._compatibility = value;
}
}
/// <summary>
/// Gets or sets optional space-delimited list of pre-approved tools.
/// </summary>
public string? AllowedTools { get; set; }
/// <summary>
/// Gets or sets the arbitrary key-value metadata for this skill.
/// </summary>
public AdditionalPropertiesDictionary? Metadata { get; set; }
/// <summary>
/// Validates a skill name against specification rules.
/// </summary>
/// <param name="name">The skill name to validate (may be <see langword="null"/>).</param>
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
/// <returns><see langword="true"/> if the name is valid; otherwise, <see langword="false"/>.</returns>
public static bool ValidateName(
string? name,
[NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(name))
{
reason = "Skill name is required.";
return false;
}
if (name.Length > MaxNameLength)
{
reason = $"Skill name must be {MaxNameLength} characters or fewer.";
return false;
}
if (!s_validNameRegex.IsMatch(name))
{
reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.";
return false;
}
reason = null;
return true;
}
/// <summary>
/// Validates a skill description against specification rules.
/// </summary>
/// <param name="description">The skill description to validate (may be <see langword="null"/>).</param>
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
/// <returns><see langword="true"/> if the description is valid; otherwise, <see langword="false"/>.</returns>
public static bool ValidateDescription(
string? description,
[NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(description))
{
reason = "Skill description is required.";
return false;
}
if (description.Length > MaxDescriptionLength)
{
reason = $"Skill description must be {MaxDescriptionLength} characters or fewer.";
return false;
}
reason = null;
return true;
}
/// <summary>
/// Validates an optional skill compatibility value against specification rules.
/// </summary>
/// <param name="compatibility">The optional compatibility value to validate (may be <see langword="null"/>).</param>
/// <param name="reason">When validation fails, contains a human-readable description of the failure.</param>
/// <returns><see langword="true"/> if the value is valid; otherwise, <see langword="false"/>.</returns>
public static bool ValidateCompatibility(
string? compatibility,
[NotNullWhen(false)] out string? reason)
{
if (compatibility?.Length > MaxCompatibilityLength)
{
reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillResource
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillResource"/> class.
/// </summary>
/// <param name="name">The resource name (e.g., relative path or identifier).</param>
/// <param name="description">An optional description of the resource.</param>
protected AgentSkillResource(string name, string? description = null)
{
this.Name = Throw.IfNullOrWhitespace(name);
this.Description = description;
}
/// <summary>
/// Gets the resource name.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the optional resource description.
/// </summary>
public string? Description { get; }
/// <summary>
/// Reads the resource content asynchronously.
/// </summary>
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resource content.</returns>
public abstract Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
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>
/// Abstract base class for skill scripts. A script represents an executable action associated with a skill.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillScript
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillScript"/> class.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="description">An optional description of the script.</param>
protected AgentSkillScript(string name, string? description = null)
{
this.Name = Throw.IfNullOrWhitespace(name);
this.Description = description;
}
/// <summary>
/// Gets the script name.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the optional script description.
/// </summary>
public string? Description { get; }
/// <summary>
/// Runs the script with the given arguments.
/// </summary>
/// <param name="skill">The skill that owns this script.</param>
/// <param name="arguments">Arguments for script execution.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,383 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that exposes agent skills from one or more <see cref="AgentSkillsSource"/> instances.
/// </summary>
/// <remarks>
/// <para>
/// This provider implements the progressive disclosure pattern from the
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
/// </para>
/// <list type="number">
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt.</description></item>
/// <item><description><strong>Load</strong> — the full skill body is returned via the <c>load_skill</c> tool.</description></item>
/// <item><description><strong>Read resources</strong> — supplementary content is read on demand via the <c>read_skill_resource</c> tool.</description></item>
/// <item><description><strong>Run scripts</strong> — scripts are executed via the <c>run_skill_script</c> tool (when scripts exist).</description></item>
/// </list>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class AgentSkillsProvider : AIContextProvider
{
/// <summary>
/// Placeholder token for the generated skills list in the prompt template.
/// </summary>
private const string SkillsPlaceholder = "{skills}";
/// <summary>
/// Placeholder token for the script instructions in the prompt template.
/// </summary>
private const string ScriptInstructionsPlaceholder = "{script_instructions}";
/// <summary>
/// Placeholder token for the resource instructions in the prompt template.
/// </summary>
private const string ResourceInstructionsPlaceholder = "{resource_instructions}";
private const string DefaultSkillsInstructionPrompt =
"""
You have access to skills containing domain-specific knowledge and capabilities.
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{skills}
</available_skills>
When a task aligns with a skill's domain, follow these steps in exact order:
- Use `load_skill` to retrieve the skill's instructions.
- Follow the provided guidance.
{resource_instructions}
{script_instructions}
Only load what is needed, when it is needed.
""";
private readonly AgentSkillsSource _source;
private readonly AgentSkillsProviderOptions? _options;
private readonly ILogger<AgentSkillsProvider> _logger;
private Task<AIContext>? _contextTask;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// that discovers file-based skills from a single directory.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skillPath">Path to search for skills.</param>
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
string skillPath,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? fileOptions = null,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// that discovers file-based skills from multiple directories.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skillPaths">Paths to search for skills.</param>
/// <param name="scriptRunner">Optional delegate that runs file-based scripts. Required only when skills contain scripts.</param>
/// <param name="fileOptions">Optional options that control skill discovery behavior.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
IEnumerable<string> skillPaths,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? fileOptions = null,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this(
new DeduplicatingAgentSkillsSource(
new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory),
loggerFactory),
options,
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// from a custom <see cref="AgentSkillsSource"/>. Unlike other constructors, this one does not
/// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline.
/// </summary>
/// <param name="source">The skill source providing skills.</param>
/// <param name="options">Optional configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._source = Throw.IfNull(source);
this._options = options;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentSkillsProvider>();
if (options?.SkillsInstructionPrompt is string prompt)
{
ValidatePromptTemplate(prompt, nameof(options));
}
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._options?.DisableCaching == true)
{
return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
}
return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false);
}
private async Task<AIContext> CreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
{
var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
if (skills is not { Count: > 0 })
{
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
}
bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
return new AIContext
{
Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
Tools = this.BuildTools(skills, hasScripts, hasResources),
};
}
private async Task<AIContext> GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<AIContext>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing)
{
return await existing.ConfigureAwait(false);
}
try
{
var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false);
tcs.SetResult(result);
return result;
}
catch (Exception ex)
{
this._contextTask = null;
tcs.TrySetException(ex);
throw;
}
}
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
{
IList<AIFunction> tools =
[
AIFunctionFactory.Create(
(string skillName) => this.LoadSkill(skills, skillName),
name: "load_skill",
description: "Loads the full content of a specific skill"),
];
if (hasResources)
{
tools.Add(AIFunctionFactory.Create(
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
name: "read_skill_resource",
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."));
}
if (!hasScripts)
{
return tools;
}
AIFunction scriptFunction = AIFunctionFactory.Create(
(string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
name: "run_skill_script",
description: "Runs a script associated with a skill.");
if (this._options?.ScriptApproval == true)
{
return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)];
}
return [.. tools, scriptFunction];
}
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
{
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
var sb = new StringBuilder();
foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
{
sb.AppendLine(" <skill>");
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
sb.AppendLine(" </skill>");
}
string resourceInstruction = includeResourceInstructions
? """
- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
"""
: string.Empty;
string scriptInstruction = includeScriptInstructions
? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
: string.Empty;
return new StringBuilder(promptTemplate)
.Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
.Replace(ResourceInstructionsPlaceholder, resourceInstruction)
.Replace(ScriptInstructionsPlaceholder, scriptInstruction)
.ToString();
}
private string LoadSkill(IList<AgentSkill> skills, string skillName)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
LogSkillLoading(this._logger, skillName);
return skill.Content;
}
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(resourceName))
{
return "Error: Resource name cannot be empty.";
}
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
if (resource is null)
{
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
}
try
{
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogResourceReadError(this._logger, skillName, resourceName, ex);
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
}
}
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(scriptName))
{
return "Error: Script name cannot be empty.";
}
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
if (skill == null)
{
return $"Error: Skill '{skillName}' not found.";
}
var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
if (script is null)
{
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
}
try
{
return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogScriptExecutionError(this._logger, skillName, scriptName, ex);
return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'.";
}
}
/// <summary>
/// Validates that a custom prompt template contains the required placeholder tokens.
/// </summary>
private static void ValidatePromptTemplate(string template, string paramName)
{
if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.",
paramName);
}
if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.",
paramName);
}
if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
$"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.",
paramName);
}
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
private static partial void LogSkillLoading(ILogger logger, string skillName);
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
[LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")]
private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception);
}
@@ -0,0 +1,192 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Fluent builder for constructing an <see cref="AgentSkillsProvider"/> backed by a composite source.
/// </summary>
/// <remarks>
/// <code>
/// var provider = new AgentSkillsProviderBuilder()
/// .UseFileSkills("/path/to/skills")
/// .Build();
/// </code>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsProviderBuilder
{
private readonly List<Func<AgentFileSkillScriptRunner?, ILoggerFactory?, AgentSkillsSource>> _sourceFactories = [];
private AgentSkillsProviderOptions? _options;
private ILoggerFactory? _loggerFactory;
private AgentFileSkillScriptRunner? _scriptRunner;
private Func<AgentSkill, bool>? _filter;
/// <summary>
/// Adds a file-based skill source that discovers skills from a filesystem directory.
/// </summary>
/// <param name="skillPath">Path to search for skills.</param>
/// <param name="options">Optional options that control skill discovery behavior.</param>
/// <param name="scriptRunner">
/// Optional runner for file-based scripts. When provided, overrides the builder-level runner
/// set via <see cref="UseFileScriptRunner"/>.
/// </param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
{
return this.UseFileSkills([skillPath], options, scriptRunner);
}
/// <summary>
/// Adds a file-based skill source that discovers skills from multiple filesystem directories.
/// </summary>
/// <param name="skillPaths">Paths to search for skills.</param>
/// <param name="options">Optional options that control skill discovery behavior.</param>
/// <param name="scriptRunner">
/// Optional runner for file-based scripts. When provided, overrides the builder-level runner
/// set via <see cref="UseFileScriptRunner"/>.
/// </param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseFileSkills(IEnumerable<string> skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null)
{
this._sourceFactories.Add((builderScriptRunner, loggerFactory) =>
{
var resolvedRunner = scriptRunner
?? builderScriptRunner
?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}.");
return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory);
});
return this;
}
/// <summary>
/// Adds a custom skill source.
/// </summary>
/// <param name="source">The custom skill source.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source)
{
_ = Throw.IfNull(source);
this._sourceFactories.Add((_, _) => source);
return this;
}
/// <summary>
/// Sets a custom system prompt template.
/// </summary>
/// <param name="promptTemplate">The prompt template with <c>{skills}</c> placeholder for the skills list,
/// <c>{resource_instructions}</c> for optional resource instructions,
/// and <c>{script_instructions}</c> for optional script instructions.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate)
{
this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate;
return this;
}
/// <summary>
/// Enables or disables the script approval gate.
/// </summary>
/// <param name="enabled">Whether script execution requires approval.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true)
{
this.GetOrCreateOptions().ScriptApproval = enabled;
return this;
}
/// <summary>
/// Sets the runner for file-based skill scripts.
/// </summary>
/// <param name="runner">The delegate that runs file-based scripts.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner)
{
this._scriptRunner = Throw.IfNull(runner);
return this;
}
/// <summary>
/// Sets the logger factory.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
return this;
}
/// <summary>
/// Sets a filter predicate that controls which skills are included.
/// </summary>
/// <remarks>
/// Skills for which the predicate returns <see langword="true"/> are kept;
/// others are excluded. Only one filter is supported; calling this method
/// again replaces any previously set filter.
/// </remarks>
/// <param name="predicate">A predicate that determines which skills to include.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseFilter(Func<AgentSkill, bool> predicate)
{
_ = Throw.IfNull(predicate);
this._filter = predicate;
return this;
}
/// <summary>
/// Configures the <see cref="AgentSkillsProviderOptions"/> using the provided delegate.
/// </summary>
/// <param name="configure">A delegate to configure the options.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseOptions(Action<AgentSkillsProviderOptions> configure)
{
_ = Throw.IfNull(configure);
configure(this.GetOrCreateOptions());
return this;
}
/// <summary>
/// Builds the <see cref="AgentSkillsProvider"/>.
/// </summary>
/// <returns>A configured <see cref="AgentSkillsProvider"/>.</returns>
public AgentSkillsProvider Build()
{
var resolvedSources = new List<AgentSkillsSource>(this._sourceFactories.Count);
foreach (var factory in this._sourceFactories)
{
resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory));
}
AgentSkillsSource source;
if (resolvedSources.Count == 1)
{
source = resolvedSources[0];
}
else
{
source = new AggregatingAgentSkillsSource(resolvedSources);
}
// Apply user-specified filter, then dedup.
if (this._filter != null)
{
source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory);
}
source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory);
return new AgentSkillsProvider(source, this._options, this._loggerFactory);
}
private AgentSkillsProviderOptions GetOrCreateOptions()
{
return this._options ??= new AgentSkillsProviderOptions();
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Configuration options for <see cref="AgentSkillsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsProviderOptions
{
/// <summary>
/// Gets or sets a custom system prompt template for advertising skills.
/// The template must contain <c>{skills}</c> as the placeholder for the generated skills list,
/// <c>{resource_instructions}</c> for resource instructions,
/// and <c>{script_instructions}</c> for script instructions.
/// When <see langword="null"/>, a default template is used.
/// </summary>
public string? SkillsInstructionPrompt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether script execution requires approval.
/// When <see langword="true"/>, script execution is blocked until approved.
/// Defaults to <see langword="false"/>.
/// </summary>
public bool ScriptApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether caching of tools and instructions is disabled.
/// When <see langword="false"/> (the default), the provider caches the tools and instructions
/// after the first build and returns the cached instance on subsequent calls.
/// Set to <see langword="true"/> to rebuild tools and instructions on every invocation.
/// </summary>
public bool DisableCaching { get; set; }
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for skill sources. A skill source provides skills from a specific origin
/// (filesystem, remote server, database, in-memory, etc.).
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSkillsSource
{
/// <summary>
/// Gets the skills provided by this source.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of skills from this source.</returns>
public abstract Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source that aggregates multiple child sources, preserving their registration order.
/// </summary>
/// <remarks>
/// Skills from each child source are returned in the order the sources were registered,
/// with each source's skills appended sequentially. No deduplication or filtering is applied.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource
{
private readonly IEnumerable<AgentSkillsSource> _sources;
/// <summary>
/// Initializes a new instance of the <see cref="AggregatingAgentSkillsSource"/> class.
/// </summary>
/// <param name="sources">The child sources to aggregate.</param>
public AggregatingAgentSkillsSource(IEnumerable<AgentSkillsSource> sources)
{
this._sources = Throw.IfNull(sources);
}
/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
var allSkills = new List<AgentSkill>();
foreach (var source in this._sources)
{
var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
allSkills.AddRange(skills);
}
return allSkills;
}
}
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource
{
private readonly ILogger<DeduplicatingAgentSkillsSource> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeduplicatingAgentSkillsSource"/> class.
/// </summary>
/// <param name="innerSource">The inner source to deduplicate.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null)
: base(innerSource)
{
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<DeduplicatingAgentSkillsSource>();
}
/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var deduplicated = new List<AgentSkill>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var skill in allSkills)
{
if (seen.Add(skill.Frontmatter.Name))
{
deduplicated.Add(skill);
}
else
{
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name);
}
}
return deduplicated;
}
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName);
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for skill sources that delegate operations to an inner source
/// while allowing for extensibility and customization.
/// </summary>
/// <remarks>
/// <see cref="DelegatingAgentSkillsSource"/> implements the decorator pattern for <see cref="AgentSkillsSource"/>,
/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication,
/// filtering, etc.) while delegating core operations to an underlying source.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSkillsSource"/> class with the specified inner source.
/// </summary>
/// <param name="innerSource">The underlying skill source that will handle the core operations.</param>
protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource)
{
this.InnerSource = Throw.IfNull(innerSource);
}
/// <summary>
/// Gets the inner skill source that receives delegated operations.
/// </summary>
protected AgentSkillsSource InnerSource { get; }
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
=> this.InnerSource.GetSkillsAsync(cancellationToken);
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source decorator that filters skills using a caller-supplied predicate.
/// </summary>
/// <remarks>
/// Skills for which the predicate returns <see langword="true"/> are included in the result;
/// skills for which it returns <see langword="false"/> are excluded and logged at debug level.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource
{
private readonly Func<AgentSkill, bool> _predicate;
private readonly ILogger<FilteringAgentSkillsSource> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FilteringAgentSkillsSource"/> class.
/// </summary>
/// <param name="innerSource">The inner source whose skills will be filtered.</param>
/// <param name="predicate">
/// A predicate that determines which skills to include. Skills for which the predicate
/// returns <see langword="true"/> are kept; others are excluded.
/// </param>
/// <param name="loggerFactory">Optional logger factory.</param>
public FilteringAgentSkillsSource(
AgentSkillsSource innerSource,
Func<AgentSkill, bool> predicate,
ILoggerFactory? loggerFactory = null)
: base(innerSource)
{
this._predicate = Throw.IfNull(predicate);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FilteringAgentSkillsSource>();
}
/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var filtered = new List<AgentSkill>();
foreach (var skill in allSkills)
{
if (this._predicate(skill))
{
filtered.Add(skill);
}
else
{
LogSkillFiltered(this._logger, skill.Frontmatter.Name);
}
}
return filtered;
}
[LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")]
private static partial void LogSkillFiltered(ILogger logger, string skillName);
}
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AgentSkill"/> discovered from a filesystem directory backed by a SKILL.md file.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkill : AgentSkill
{
private readonly IReadOnlyList<AgentSkillResource> _resources;
private readonly IReadOnlyList<AgentSkillScript> _scripts;
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
/// </summary>
/// <param name="frontmatter">The parsed frontmatter metadata for this skill.</param>
/// <param name="content">The full raw SKILL.md file content including YAML frontmatter.</param>
/// <param name="path">Absolute path to the directory containing this skill.</param>
/// <param name="resources">Resources discovered for this skill.</param>
/// <param name="scripts">Scripts discovered for this skill.</param>
internal AgentFileSkill(
AgentSkillFrontmatter frontmatter,
string content,
string path,
IReadOnlyList<AgentSkillResource>? resources = null,
IReadOnlyList<AgentSkillScript>? scripts = null)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this.Content = Throw.IfNull(content);
this.Path = Throw.IfNullOrWhitespace(path);
this._resources = resources ?? [];
this._scripts = scripts ?? [];
}
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; }
/// <inheritdoc/>
public override string Content { get; }
/// <summary>
/// Gets the directory path where the skill was discovered.
/// </summary>
public string Path { get; }
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory.
/// </summary>
internal sealed class AgentFileSkillResource : AgentSkillResource
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillResource"/> class.
/// </summary>
/// <param name="name">The resource name (relative path within the skill directory).</param>
/// <param name="fullPath">The absolute file path to the resource.</param>
public AgentFileSkillResource(string name, string fullPath)
: base(name)
{
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
}
/// <summary>
/// Gets the absolute file path to the resource.
/// </summary>
public string FullPath { get; }
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var reader = new StreamReader(this.FullPath, Encoding.UTF8);
return await reader.ReadToEndAsync().ConfigureAwait(false);
#endif
}
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
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>
/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillScript : AgentSkillScript
{
private readonly AgentFileSkillScriptRunner? _runner;
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillScript"/> class.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="fullPath">The absolute file path to the script.</param>
/// <param name="runner">Optional external runner for running the script. An <see cref="InvalidOperationException"/> is thrown from <see cref="RunAsync"/> if no runner is provided.</param>
internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null)
: base(name)
{
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
this._runner = runner;
}
/// <summary>
/// Gets the absolute file path to the script.
/// </summary>
public string FullPath { get; }
/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
{
if (skill is not AgentFileSkill fileSkill)
{
throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'.");
}
if (this._runner is null)
{
throw new InvalidOperationException(
$"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " +
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
}
return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Delegate for running file-based skill scripts.
/// </summary>
/// <remarks>
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
/// </remarks>
/// <param name="skill">The skill that owns the script.</param>
/// <param name="script">The file-based script to run.</param>
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate Task<object?> AgentFileSkillScriptRunner(
AgentFileSkill skill,
AgentFileSkillScript script,
AIFunctionArguments arguments,
CancellationToken cancellationToken);
@@ -2,151 +2,135 @@
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.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Discovers, parses, and validates SKILL.md files from filesystem directories.
/// A skill source that discovers skills from filesystem directories containing SKILL.md files.
/// </summary>
/// <remarks>
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource paths are checked against path traversal and symlink escape attacks.
/// Resource and script paths are checked against path traversal and symlink escape attacks.
/// </remarks>
internal sealed partial class FileAgentSkillLoader
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
private const int MaxNameLength = 64;
private const int MaxDescriptionLength = 1024;
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
// Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools),
// Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
// "description: \"A skill\"" → (description, A skill, _)
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Validates skill names: lowercase letters, numbers, and hyphens only;
// must not start or end with a hyphen; must not contain consecutive hyphens.
// Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
// Matches a "metadata:" line followed by indented sub-key/value pairs.
// Group 1 captures the entire indented block beneath the metadata key.
private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
private readonly ILogger _logger;
// Matches indented YAML "key: value" lines within a metadata block.
// Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value.
private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="allowedResourceExtensions">File extensions to recognize as skill resources. When <see langword="null"/>, defaults are used.</param>
internal FileAgentSkillLoader(ILogger logger, IEnumerable<string>? allowedResourceExtensions = null)
/// <param name="skillPath">Path to search for skills.</param>
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
/// <param name="options">Optional options that control skill discovery behavior.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentFileSkillsSource(
string skillPath,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this([skillPath], scriptRunner, options, loggerFactory)
{
this._logger = logger;
ValidateExtensions(allowedResourceExtensions);
this._allowedResourceExtensions = new HashSet<string>(
allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Discovers skill directories and loads valid skills from them.
/// Initializes a new instance of the <see cref="AgentFileSkillsSource"/> class.
/// </summary>
/// <param name="skillPaths">Paths to search for skills. Each path can point to an individual skill folder or a parent folder.</param>
/// <returns>A dictionary of loaded skills keyed by skill name.</returns>
internal Dictionary<string, FileAgentSkill> DiscoverAndLoadSkills(IEnumerable<string> skillPaths)
/// <param name="skillPaths">Paths to search for skills.</param>
/// <param name="scriptRunner">Optional runner for file-based scripts. Required only when skills contain scripts.</param>
/// <param name="options">Optional options that control skill discovery behavior.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentFileSkillsSource(
IEnumerable<string> skillPaths,
AgentFileSkillScriptRunner? scriptRunner = null,
AgentFileSkillsSourceOptions? options = null,
ILoggerFactory? loggerFactory = null)
{
var skills = new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase);
this._skillPaths = Throw.IfNull(skillPaths);
var discoveredPaths = DiscoverSkillDirectories(skillPaths);
var resolvedOptions = options ?? new AgentFileSkillsSourceOptions();
ValidateExtensions(resolvedOptions.AllowedResourceExtensions);
ValidateExtensions(resolvedOptions.AllowedScriptExtensions);
this._allowedResourceExtensions = new HashSet<string>(
resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions,
StringComparer.OrdinalIgnoreCase);
this._allowedScriptExtensions = new HashSet<string>(
resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._scriptRunner = scriptRunner;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentFileSkillsSource>();
}
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
var skills = new List<AgentSkill>();
foreach (string skillPath in discoveredPaths)
{
FileAgentSkill? skill = this.ParseSkillFile(skillPath);
AgentFileSkill? skill = this.ParseSkillDirectory(skillPath);
if (skill is null)
{
continue;
}
if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing))
{
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath);
// Skip duplicate skill names, keeping the first one found.
continue;
}
skills[skill.Frontmatter.Name] = skill;
skills.Add(skill);
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
}
LogSkillsLoadedTotal(this._logger, skills.Count);
return skills;
}
/// <summary>
/// Reads a resource file from disk with path traversal and symlink guards.
/// </summary>
/// <param name="skill">The skill that owns the resource.</param>
/// <param name="resourceName">Relative path of the resource within the skill directory.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The UTF-8 text content of the resource file.</returns>
/// <exception cref="InvalidOperationException">
/// The resource is not registered, resolves outside the skill directory, or does not exist.
/// </exception>
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
{
resourceName = NormalizeResourcePath(resourceName);
if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
}
string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
}
if (!File.Exists(fullPath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
}
if (HasSymlinkInPath(fullPath, normalizedSourcePath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
}
LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
#if NET
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
#endif
return Task.FromResult(skills as IList<AgentSkill>);
}
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
@@ -185,30 +169,30 @@ internal sealed partial class FileAgentSkillLoader
}
}
private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath)
{
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter))
{
return null;
}
List<string> resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name);
return new FileAgentSkill(
return new AgentFileSkill(
frontmatter: frontmatter,
body: body,
sourcePath: skillDirectoryFullPath,
resourceNames: resourceNames);
content: content,
path: skillDirectoryFullPath,
resources: resources,
scripts: scripts);
}
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter)
{
frontmatter = null!;
body = null!;
frontmatter = null;
Match match = s_frontmatterRegex.Match(content);
if (!match.Success)
@@ -217,10 +201,13 @@ internal sealed partial class FileAgentSkillLoader
return false;
}
string yamlContent = match.Groups[1].Value.Trim();
string? name = null;
string? description = null;
string yamlContent = match.Groups[1].Value.Trim();
string? license = null;
string? compatibility = null;
string? allowedTools = null;
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
{
@@ -235,50 +222,62 @@ internal sealed partial class FileAgentSkillLoader
{
description = value;
}
else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase))
{
license = value;
}
else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase))
{
compatibility = value;
}
else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase))
{
allowedTools = value;
}
}
if (string.IsNullOrWhiteSpace(name))
// Parse metadata block (indented key-value pairs under "metadata:").
AdditionalPropertiesDictionary? metadata = null;
Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent);
if (metadataMatch.Success)
{
LogMissingFrontmatterField(this._logger, skillFilePath, "name");
metadata = [];
foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value))
{
metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
}
}
if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) ||
!AgentSkillFrontmatter.ValidateDescription(description, out validationReason))
{
LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason);
return false;
}
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility)
{
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.");
return false;
}
License = license,
AllowedTools = allowedTools,
Metadata = metadata,
};
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
// GetDirectoryName strips the filename → "/skills/my-skill".
// GetFileName then extracts the last segment → "my-skill".
// This gives us the skill's parent directory name to validate against the frontmatter name.
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
if (!string.Equals(name, directoryName, StringComparison.Ordinal))
if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal))
{
if (this._logger.IsEnabled(LogLevel.Error))
{
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName));
}
frontmatter = null;
return false;
}
if (string.IsNullOrWhiteSpace(description))
{
LogMissingFrontmatterField(this._logger, skillFilePath, "description");
return false;
}
if (description.Length > MaxDescriptionLength)
{
LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer.");
return false;
}
frontmatter = new SkillFrontmatter(name, description);
body = content.Substring(match.Index + match.Length).TrimStart();
return true;
}
@@ -287,15 +286,15 @@ internal sealed partial class FileAgentSkillLoader
/// </summary>
/// <remarks>
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
/// matches <see cref="_allowedResourceExtensions"/>, excluding <c>SKILL.md</c> itself. Each candidate
/// matches the allowed set, excluding <c>SKILL.md</c> itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
/// </remarks>
private List<string> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var resources = new List<string>();
var resources = new List<AgentFileSkillResource>();
#if NET
var enumerationOptions = new EnumerationOptions
@@ -326,21 +325,21 @@ internal sealed partial class FileAgentSkillLoader
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms
// (redundant separators, 8.3 short names, etc.) that would produce
// malformed relative resource names.
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
@@ -351,30 +350,86 @@ internal sealed partial class FileAgentSkillLoader
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
resources.Add(NormalizeResourcePath(relativePath));
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
return resources;
}
/// <summary>
/// Checks that <paramref name="fullPath"/> is under <paramref name="normalizedDirectoryPath"/>,
/// guarding against path traversal attacks.
/// Scans a skill directory for script files matching the configured extensions.
/// </summary>
private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
/// <remarks>
/// Recursively walks the skill directory and collects files whose extension
/// matches the allowed set. Each candidate is validated against path-traversal
/// and symlink-escape checks; unsafe files are skipped with a warning.
/// </remarks>
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
var scripts = new List<AgentFileSkillScript>();
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
continue;
}
// Normalize the enumerated path to guard against non-canonical forms
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
return scripts;
}
/// <summary>
/// Checks whether any segment in <paramref name="fullPath"/> (relative to
/// <paramref name="normalizedDirectoryPath"/>) is a symlink (reparse point).
/// Uses <see cref="FileAttributes.ReparsePoint"/> which is available on all target frameworks.
/// Checks whether any segment in the path (relative to the directory) is a symlink.
/// </summary>
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
{
@@ -399,11 +454,10 @@ internal sealed partial class FileAgentSkillLoader
}
/// <summary>
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
/// treated as the same resource.
/// Normalizes a relative path by replacing backslashes with forward slashes
/// and trimming a leading "./" prefix.
/// </summary>
private static string NormalizeResourcePath(string path)
private static string NormalizePath(string path)
{
if (path.IndexOf('\\') >= 0)
{
@@ -419,8 +473,7 @@ internal sealed partial class FileAgentSkillLoader
}
/// <summary>
/// Replaces control characters in a file path with '?' to prevent log injection
/// via crafted filenames (e.g., filenames containing newlines on Linux).
/// Replaces control characters in a file path with '?' to prevent log injection.
/// </summary>
private static string SanitizePathForLog(string path)
{
@@ -449,7 +502,7 @@ internal sealed partial class FileAgentSkillLoader
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
{
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions");
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
}
}
@@ -467,9 +520,6 @@ internal sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")]
private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
@@ -479,15 +529,15 @@ internal sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Configuration options for file-based skill sources.
/// </summary>
/// <remarks>
/// Use this class to configure file-based skill discovery without relying on
/// positional constructor or method parameters. New options can be added here
/// without breaking existing callers.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillsSourceOptions
{
/// <summary>
/// Gets or sets the allowed file extensions for skill resources.
/// When <see langword="null"/>, defaults to <c>.md</c>, <c>.json</c>, <c>.yaml</c>,
/// <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>.
/// </summary>
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
/// <summary>
/// Gets or sets the allowed file extensions for skill scripts.
/// When <see langword="null"/>, defaults to <c>.py</c>, <c>.js</c>, <c>.sh</c>,
/// <c>.ps1</c>, <c>.cs</c>, <c>.csx</c>.
/// </summary>
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
}
@@ -1,56 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a loaded Agent Skill discovered from a filesystem directory.
/// </summary>
/// <remarks>
/// Each skill is backed by a <c>SKILL.md</c> file containing YAML frontmatter (name and description)
/// and a markdown body with instructions. Resource files referenced in the body are validated at
/// discovery time and read from disk on demand.
/// </remarks>
internal sealed class FileAgentSkill
{
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
/// </summary>
/// <param name="frontmatter">Parsed YAML frontmatter (name and description).</param>
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
public FileAgentSkill(
SkillFrontmatter frontmatter,
string body,
string sourcePath,
IReadOnlyList<string>? resourceNames = null)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this.Body = Throw.IfNull(body);
this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
this.ResourceNames = resourceNames ?? [];
}
/// <summary>
/// Gets the parsed YAML frontmatter (name and description).
/// </summary>
public SkillFrontmatter Frontmatter { get; }
/// <summary>
/// Gets the SKILL.md body content (without the YAML frontmatter).
/// </summary>
public string Body { get; }
/// <summary>
/// Gets the directory path where the skill was discovered.
/// </summary>
public string SourcePath { get; }
/// <summary>
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
/// </summary>
public IReadOnlyList<string> ResourceNames { get; }
}
@@ -1,222 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that discovers and exposes Agent Skills from filesystem directories.
/// </summary>
/// <remarks>
/// <para>
/// This provider implements the progressive disclosure pattern from the
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
/// </para>
/// <list type="number">
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt (~100 tokens per skill).</description></item>
/// <item><description><strong>Load</strong> — the full SKILL.md body is returned via the <c>load_skill</c> tool.</description></item>
/// <item><description><strong>Read resources</strong> — supplementary files are read from disk on demand via the <c>read_skill_resource</c> tool.</description></item>
/// </list>
/// <para>
/// Skills are discovered by searching the configured directories for <c>SKILL.md</c> files.
/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
/// </para>
/// <para>
/// <strong>Security:</strong> this provider only reads static content. Skill metadata is XML-escaped
/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
/// Only use skills from trusted sources.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class FileAgentSkillsProvider : AIContextProvider
{
private const string DefaultSkillsInstructionPrompt =
"""
You have access to skills containing domain-specific knowledge and capabilities.
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{0}
</available_skills>
When a task aligns with a skill's domain:
1. Use `load_skill` to retrieve the skill's instructions
2. Follow the provided guidance
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
Only load what is needed, when it is needed.
""";
private readonly Dictionary<string, FileAgentSkill> _skills;
private readonly ILogger<FileAgentSkillsProvider> _logger;
private readonly FileAgentSkillLoader _loader;
private readonly AITool[] _tools;
private readonly string? _skillsInstructionPrompt;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches a single directory for skills.
/// </summary>
/// <param name="skillPath">Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.</param>
/// <param name="options">Optional configuration for prompt customization.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
: this([skillPath], options, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches multiple directories for skills.
/// </summary>
/// <param name="skillPaths">Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.</param>
/// <param name="options">Optional configuration for prompt customization.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public FileAgentSkillsProvider(IEnumerable<string> skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
this._tools =
[
AIFunctionFactory.Create(
this.LoadSkill,
name: "load_skill",
description: "Loads the full instructions for a specific skill."),
AIFunctionFactory.Create(
this.ReadSkillResourceAsync,
name: "read_skill_resource",
description: "Reads a file associated with a skill, such as references or assets."),
];
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._skills.Count == 0)
{
return base.ProvideAIContextAsync(context, cancellationToken);
}
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._skillsInstructionPrompt,
Tools = this._tools
});
}
private string LoadSkill(string skillName)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
{
return $"Error: Skill '{skillName}' not found.";
}
LogSkillLoading(this._logger, skillName);
return skill.Body;
}
private async Task<string> ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(resourceName))
{
return "Error: Resource name cannot be empty.";
}
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
{
return $"Error: Skill '{skillName}' not found.";
}
try
{
return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogResourceReadError(this._logger, skillName, resourceName, ex);
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
}
}
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
{
string promptTemplate = DefaultSkillsInstructionPrompt;
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
{
try
{
_ = string.Format(optionsInstructions, string.Empty);
}
catch (FormatException ex)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt is not a valid format string.",
nameof(options),
ex);
}
if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
nameof(options));
}
promptTemplate = optionsInstructions;
}
if (skills.Count == 0)
{
return null;
}
var sb = new StringBuilder();
// Order by name for deterministic prompt output across process restarts
// (Dictionary enumeration order is not guaranteed and varies with hash randomization).
foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
{
sb.AppendLine(" <skill>");
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
sb.AppendLine(" </skill>");
}
return string.Format(promptTemplate, sb.ToString().TrimEnd());
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
private static partial void LogSkillLoading(ILogger logger, string skillName);
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
}
@@ -1,32 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Configuration options for <see cref="FileAgentSkillsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAgentSkillsProviderOptions
{
/// <summary>
/// Gets or sets a custom system prompt template for advertising skills.
/// Use <c>{0}</c> as the placeholder for the generated skills list.
/// When <see langword="null"/>, a default template is used.
/// </summary>
public string? SkillsInstructionPrompt { get; set; }
/// <summary>
/// Gets or sets the file extensions recognized as discoverable skill resources.
/// Each value must start with a <c>'.'</c> character (for example, <c>.md</c>), and
/// extension comparisons are performed in a case-insensitive manner.
/// Files in the skill directory (and its subdirectories) whose extension matches
/// one of these values will be automatically discovered as resources.
/// When <see langword="null"/>, a default set of extensions is used
/// (<c>.md</c>, <c>.json</c>, <c>.yaml</c>, <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>).
/// </summary>
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
}
@@ -1,32 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
/// </summary>
internal sealed class SkillFrontmatter
{
/// <summary>
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
/// </summary>
/// <param name="name">Skill name.</param>
/// <param name="description">Skill description.</param>
public SkillFrontmatter(string name, string description)
{
this.Name = Throw.IfNullOrWhitespace(name);
this.Description = Throw.IfNullOrWhitespace(description);
}
/// <summary>
/// Gets the skill name. Lowercase letters, numbers, and hyphens only.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the skill description. Used for discovery in the system prompt.
/// </summary>
public string Description { get; }
}