diff --git a/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs b/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs new file mode 100644 index 000000000000..dfef8cf4b701 --- /dev/null +++ b/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Text; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Plugins; + +namespace Agents; + +public class DeclarativeAgents(ITestOutputHelper output) : BaseAgentsTest(output) +{ + [InlineData("SchedulingAssistant.json", "Read the body of my last five emails, if any contain a meeting request for today, check that it's already on my calendar, if not, call out which email it is.")] + [Theory] + public async Task LoadsAgentFromDeclarativeAgentManifestAsync(string agentFileName, string input) + { + var kernel = CreateKernel(); + kernel.AutoFunctionInvocationFilters.Add(new ExpectedSchemaFunctionFilter()); + var manifestLookupDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "Resources", "DeclarativeAgents"); + var manifestFilePath = Path.Combine(manifestLookupDirectory, agentFileName); + + var parameters = await CopilotAgentBasedPlugins.GetAuthenticationParametersAsync(); + + var agent = await kernel.CreateChatCompletionAgentFromDeclarativeAgentManifestAsync(manifestFilePath, parameters); + + Assert.NotNull(agent); + Assert.NotNull(agent.Name); + Assert.NotEmpty(agent.Name); + Assert.NotNull(agent.Description); + Assert.NotEmpty(agent.Description); + Assert.NotNull(agent.Instructions); + Assert.NotEmpty(agent.Instructions); + + ChatMessageContent message = new(AuthorRole.User, input); + ChatHistory chatHistory = [message]; + StringBuilder sb = new(); + await foreach (ChatMessageContent response in agent.InvokeAsync(chatHistory)) + { + chatHistory.Add(response); + sb.Append(response.Content); + } + Assert.NotEmpty(chatHistory.Skip(1)); + } + private Kernel CreateKernel() + { + IKernelBuilder builder = Kernel.CreateBuilder(); + + base.AddChatCompletionToKernel(builder); + + return builder.Build(); + } + private sealed class ExpectedSchemaFunctionFilter : IAutoFunctionInvocationFilter + {//TODO: this eventually needs to be added to all CAP or DA but we're still discussing where should those facilitators live + public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + await next(context); + + if (context.Result.ValueType == typeof(RestApiOperationResponse)) + { + var openApiResponse = context.Result.GetValue(); + if (openApiResponse?.ExpectedSchema is not null) + { + openApiResponse.ExpectedSchema = null; + } + } + } + } +} diff --git a/dotnet/samples/Concepts/Concepts.csproj b/dotnet/samples/Concepts/Concepts.csproj index 6246e639ea4f..ef46504416b4 100644 --- a/dotnet/samples/Concepts/Concepts.csproj +++ b/dotnet/samples/Concepts/Concepts.csproj @@ -131,6 +131,9 @@ Always + + Always + diff --git a/dotnet/samples/Concepts/Plugins/CopilotAgentBasedPlugins.cs b/dotnet/samples/Concepts/Plugins/CopilotAgentBasedPlugins.cs index f025902e2701..748e8f3e3ee6 100644 --- a/dotnet/samples/Concepts/Plugins/CopilotAgentBasedPlugins.cs +++ b/dotnet/samples/Concepts/Plugins/CopilotAgentBasedPlugins.cs @@ -87,9 +87,8 @@ private void WriteSampleHeadingToConsole(string pluginToTest, string functionToT Console.WriteLine($"======== Calling Plugin Function: {pluginToTest}.{functionToTest} with parameters {arguments?.Select(x => x.Key + " = " + x.Value).Aggregate((x, y) => x + ", " + y)} ========"); Console.WriteLine(); } - private async Task AddCopilotAgentPluginsAsync(Kernel kernel, params string[] pluginNames) + internal static async Task GetAuthenticationParametersAsync() { -#pragma warning disable SKEXP0050 if (TestConfiguration.MSGraph.Scopes is null) { throw new InvalidOperationException("Missing Scopes configuration for Microsoft Graph API."); @@ -131,6 +130,12 @@ private async Task AddCopilotAgentPluginsAsync(Kernel kernel, params string[] pl { "https://api.nasa.gov/planetary", nasaOpenApiFunctionExecutionParameters } } }; + return apiManifestPluginParameters; + } + private async Task AddCopilotAgentPluginsAsync(Kernel kernel, params string[] pluginNames) + { +#pragma warning disable SKEXP0050 + var apiManifestPluginParameters = await GetAuthenticationParametersAsync().ConfigureAwait(false); var manifestLookupDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "Resources", "Plugins", "CopilotAgentPlugins"); foreach (var pluginName in pluginNames) diff --git a/dotnet/samples/Concepts/Resources/DeclarativeAgents/SchedulingAssistant.json b/dotnet/samples/Concepts/Resources/DeclarativeAgents/SchedulingAssistant.json new file mode 100644 index 000000000000..214dad4d1cac --- /dev/null +++ b/dotnet/samples/Concepts/Resources/DeclarativeAgents/SchedulingAssistant.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://aka.ms/json-schemas/copilot/declarative-agent/v1.0/schema.json", + "version": "v1.0", + "instructions": "$[file('scheduling-assistant-instructions.txt')]", + "name": "SchedulingAssistant", + "description": "This agent helps you schedule meetings and send messages.", + "actions": [ + { + "id": "CalendarPlugin", + "file": "../Plugins/CopilotAgentPlugins/CalendarPlugin/calendar-apiplugin.json" + }, + { + "id": "MessagesPlugin", + "file": "../Plugins/CopilotAgentPlugins/MessagesPlugin/messages-apiplugin.json" + } + ] +} diff --git a/dotnet/samples/Concepts/Resources/DeclarativeAgents/scheduling-assistant-instructions.txt b/dotnet/samples/Concepts/Resources/DeclarativeAgents/scheduling-assistant-instructions.txt new file mode 100644 index 000000000000..fff8c35f271d --- /dev/null +++ b/dotnet/samples/Concepts/Resources/DeclarativeAgents/scheduling-assistant-instructions.txt @@ -0,0 +1 @@ +You are a personal assistant to the user. You help recap the last received emails, the upcoming meetings, and reply to any emails upon user's request. You can only use the calendar and messages plugins. Whenever you make HTTP REST request, you MUST select the fewest fields you need from the API to ensure a great user experience. If you need to select the body field, you MUST select the bodyPreview field instead. \ No newline at end of file diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index d8cd91b9100d..06af107a0a5d 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -27,9 +27,9 @@ public abstract class Agent /// /// The identifier of the agent (optional). /// - /// + /// /// Default to a random guid value, but may be overridden. - /// + /// public string Id { get; init; } = Guid.NewGuid().ToString(); /// diff --git a/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/CopilotAgentPluginKernelExtensions.cs b/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/CopilotAgentPluginKernelExtensions.cs index e9e401ff2960..73561a0a11fd 100644 --- a/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/CopilotAgentPluginKernelExtensions.cs +++ b/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/CopilotAgentPluginKernelExtensions.cs @@ -79,12 +79,12 @@ public static async Task CreatePluginFromCopilotAgentPluginAsync( var results = await PluginManifestDocument.LoadAsync(CopilotAgentFileJsonContents, new ReaderOptions { - ValidationRules = new() // Disable validation rules + ValidationRules = [] // Disable validation rules }).ConfigureAwait(false); if (!results.IsValid) { - var messages = results.Problems.Select(p => p.Message).Aggregate((a, b) => $"{a}, {b}"); + var messages = results.Problems.Select(static p => p.Message).Aggregate(static (a, b) => $"{a}, {b}"); throw new InvalidOperationException($"Error loading the manifest: {messages}"); } diff --git a/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/DeclarativeAgentExtensions.cs b/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/DeclarativeAgentExtensions.cs new file mode 100644 index 000000000000..b3d2b962adf5 --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi.Extensions/Extensions/DeclarativeAgentExtensions.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Plugins.Manifest; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Plugins.OpenApi; +using Microsoft.SemanticKernel.Plugins.OpenApi.Extensions; + +namespace Microsoft.SemanticKernel; + +/// +/// Provides extension methods for loading and managing declarative agents and their Copilot Agent Plugins. +/// +public static class DeclarativeAgentExtensions +{ + /// + /// Creates a chat completion agent from a declarative agent manifest asynchronously. + /// + /// The type of the agent to create. + /// The kernel instance. + /// The file path of the declarative agent manifest. + /// Optional parameters for the Copilot Agent Plugin setup. + /// Optional prompt execution settings. Ensure you enable function calling. + /// Optional cancellation token. + /// A task that represents the asynchronous operation. The task result contains the created chat completion agent. + public static async Task CreateChatCompletionAgentFromDeclarativeAgentManifestAsync( + this Kernel kernel, + string filePath, + CopilotAgentPluginParameters? pluginParameters = null, + PromptExecutionSettings? promptExecutionSettings = default, + CancellationToken cancellationToken = default) + where T : KernelAgent, new() + { + Verify.NotNull(kernel); + Verify.NotNullOrWhiteSpace(filePath); + + var loggerFactory = kernel.LoggerFactory; + var logger = loggerFactory.CreateLogger(typeof(DeclarativeAgentExtensions)) ?? NullLogger.Instance; + using var declarativeAgentFileJsonContents = DocumentLoader.LoadDocumentFromFilePathAsStream(filePath, + logger); + + var results = await DCManifestDocument.LoadAsync(declarativeAgentFileJsonContents, new ReaderOptions + { + ValidationRules = [] // Disable validation rules + }).ConfigureAwait(false); + + if (!results.IsValid) + { + var messages = results.Problems.Select(static p => p.Message).Aggregate(static (a, b) => $"{a}, {b}"); + throw new InvalidOperationException($"Error loading the manifest: {messages}"); + } + + var document = results.Document ?? throw new InvalidOperationException("Error loading the manifest"); + var manifestDirectory = Path.GetDirectoryName(filePath); + document.Instructions = await GetEffectiveInstructionsAsync(manifestDirectory, document.Instructions, logger, cancellationToken).ConfigureAwait(false); + + var agent = new T + { + Name = document.Name, + Instructions = document.Instructions, + Kernel = kernel, + Arguments = new KernelArguments(promptExecutionSettings ?? new PromptExecutionSettings() + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), + }), + Description = document.Description, + LoggerFactory = loggerFactory, + Id = string.IsNullOrEmpty(document.Id) ? Guid.NewGuid().ToString() : document.Id!, + }; + + if (document.Capabilities is { Count: > 0 }) + { + logger.LogWarning("Importing capabilities from declarative agent is not supported in semantic kernel."); + } + + if (document.Actions is { Count: > 0 }) + { + logger.LogInformation("Importing {ActionsCount} actions from declarative agent.", document.Actions.Count); + await Task.WhenAll(document.Actions.Select(action => ImportCAPFromActionAsync(action, manifestDirectory, kernel, pluginParameters, logger, cancellationToken))).ConfigureAwait(false); + } + return agent; + } + private static async Task ImportCAPFromActionAsync(DCAction action, string? manifestDirectory, Kernel kernel, CopilotAgentPluginParameters? pluginParameters, ILogger logger, CancellationToken cancellationToken) + { + try + { + var capManifestPath = GetFullPath(manifestDirectory, action.File); + logger.LogInformation("Importing action {ActionName} from declarative agent from path {Path}.", action.Id, capManifestPath); + await kernel.ImportPluginFromCopilotAgentPluginAsync(action.Id, capManifestPath, pluginParameters, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is FileNotFoundException or InvalidOperationException) + { + logger.LogError(ex, "Error importing action {ActionName} from declarative agent.", action.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error importing action {ActionName} from declarative agent.", action.Id); + throw; + } + } + private static async Task GetEffectiveInstructionsAsync(string? manifestFilePath, string? source, ILogger logger, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(source) || + !source!.StartsWith("$[file('", StringComparison.OrdinalIgnoreCase) || + !source.EndsWith("')]", StringComparison.OrdinalIgnoreCase)) + { + return source; + } +#if NETCOREAPP3_0_OR_GREATER + var filePath = source[8..^3]; +#else + var filePath = source.Substring(8, source.Length - 11); +#endif + filePath = GetFullPath(manifestFilePath, filePath); + return await DocumentLoader.LoadDocumentFromFilePathAsync(filePath, logger, cancellationToken).ConfigureAwait(false); + } + private static string GetFullPath(string? manifestDirectory, string relativeOrAbsolutePath) + { + return !Path.IsPathRooted(relativeOrAbsolutePath) && !relativeOrAbsolutePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrEmpty(manifestDirectory) + ? throw new InvalidOperationException("Invalid manifest file path.") + : Path.Combine(manifestDirectory, relativeOrAbsolutePath) + : relativeOrAbsolutePath; + } +} diff --git a/dotnet/src/Functions/Functions.OpenApi.Extensions/Functions.OpenApi.Extensions.csproj b/dotnet/src/Functions/Functions.OpenApi.Extensions/Functions.OpenApi.Extensions.csproj index 28a8ef5136b0..2a4a58f74cdf 100644 --- a/dotnet/src/Functions/Functions.OpenApi.Extensions/Functions.OpenApi.Extensions.csproj +++ b/dotnet/src/Functions/Functions.OpenApi.Extensions/Functions.OpenApi.Extensions.csproj @@ -20,5 +20,6 @@ + \ No newline at end of file