mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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}<agent_id>`,
|
||||
/// where `<agent_id>` 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);
|
||||
+209
-159
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user