diff --git a/README.md b/README.md index 46bd4cdd..73626c80 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,10 @@ Designed to be agnostic of any agent framework, language, or platform, the Seman To develop new agents and connect existing ones, see the [Assistant Development Guide](docs/ASSISTANT_DEVELOPMENT_GUIDE.md) -The repository contains a [Python Canonical Assistant](semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/canonical.py) and a [.NET Agent Example](examples/dotnet-example01) that can be used as starting points to create custom agents. +The repository contains a [Python Canonical Assistant](semantic-workbench/v1/service/semantic-workbench-assistant/semantic_workbench_assistant/canonical.py) and some [.NET Agent Examples](examples) that can be used as starting points to create custom agents. + +![Mermaid graph example](examples/dotnet-example02/docs/mermaid.png) +![ABC music example](examples/dotnet-example02/docs/abc.png) # Workbench setup diff --git a/dotnet/SemanticWorkbench.sln b/dotnet/SemanticWorkbench.sln index e7c88213..a32e46dd 100644 --- a/dotnet/SemanticWorkbench.sln +++ b/dotnet/SemanticWorkbench.sln @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkbenchConnector", "Workb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExample01", "..\examples\dotnet-example01\AgentExample01.csproj", "{3A6FE36E-B186-458C-984B-C1BBF4BFB440}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExample02", "..\examples\dotnet-example02\AgentExample02.csproj", "{46BC33EC-AA35-428D-A8B4-2C0E693C7C51}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Debug|Any CPU.Build.0 = Debug|Any CPU {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Release|Any CPU.ActiveCfg = Release|Any CPU {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Release|Any CPU.Build.0 = Release|Any CPU + {46BC33EC-AA35-428D-A8B4-2C0E693C7C51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46BC33EC-AA35-428D-A8B4-2C0E693C7C51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46BC33EC-AA35-428D-A8B4-2C0E693C7C51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46BC33EC-AA35-428D-A8B4-2C0E693C7C51}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/dotnet/SemanticWorkbench.sln.DotSettings b/dotnet/SemanticWorkbench.sln.DotSettings index 496120c1..171966c5 100644 --- a/dotnet/SemanticWorkbench.sln.DotSettings +++ b/dotnet/SemanticWorkbench.sln.DotSettings @@ -1,2 +1,5 @@  - CORS \ No newline at end of file + ABC + CORS + HTML + JSON \ No newline at end of file diff --git a/examples/dotnet-example01/.editorconfig b/examples/.editorconfig similarity index 100% rename from examples/dotnet-example01/.editorconfig rename to examples/.editorconfig diff --git a/examples/dotnet-example02/AgentExample02.csproj b/examples/dotnet-example02/AgentExample02.csproj new file mode 100644 index 00000000..f09b0fde --- /dev/null +++ b/examples/dotnet-example02/AgentExample02.csproj @@ -0,0 +1,38 @@ + + + + net8.0 + enable + enable + AgentExample02 + AgentExample02 + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/examples/dotnet-example02/MyAgent.cs b/examples/dotnet-example02/MyAgent.cs new file mode 100644 index 00000000..974e5bfc --- /dev/null +++ b/examples/dotnet-example02/MyAgent.cs @@ -0,0 +1,594 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Azure; +using Azure.AI.ContentSafety; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample02; + +public class MyAgent : AgentBase +{ + // Agent settings + public MyAgentConfig Config + { + get { return (MyAgentConfig)this.RawConfig; } + private set { this.RawConfig = value; } + } + + // Azure Content Safety + private readonly ContentSafetyClient _contentSafety; + + /// + /// Create a new agent instance + /// + /// Agent instance ID + /// Agent name + /// Agent configuration + /// Service containing the agent, used to communicate with Workbench backend + /// Agent data storage + /// Azure content safety + /// Semantic Kernel + /// App logger factory + public MyAgent( + string agentId, + string agentName, + MyAgentConfig? agentConfig, + WorkbenchConnector workbenchConnector, + IAgentServiceStorage storage, + ContentSafetyClient contentSafety, + ILoggerFactory? loggerFactory = null) + : base( + workbenchConnector, + storage, + loggerFactory?.CreateLogger() ?? new NullLogger()) + { + this.Id = agentId; + this.Name = agentName; + this.Config = agentConfig ?? new MyAgentConfig(); + this._contentSafety = contentSafety; + } + + /// + public override IAgentConfig GetDefaultConfig() + { + return new MyAgentConfig(); + } + + /// + public override IAgentConfig? ParseConfig(object data) + { + return JsonSerializer.Deserialize(JsonSerializer.Serialize(data)); + } + + /// + public override async Task ReceiveCommandAsync( + string conversationId, + Command command, + CancellationToken cancellationToken = default) + { + try + { + if (!this.Config.CommandsEnabled) { return; } + + // Support only the "say" command + if (command.CommandName.ToLowerInvariant() != "say") { return; } + + // Update the chat history to include the message received + await base.ReceiveMessageAsync(conversationId, command, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && command.Sender.Role == "assistant") { return; } + + // Create the answer content + var answer = Message.CreateChatMessage(this.Id, command.CommandParams); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + /// + public override Task ReceiveMessageAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + switch (this.Config.Behavior.ToLowerInvariant()) + { + case "echo": return this.EchoExampleAsync(conversationId, message, cancellationToken); + case "reverse": return this.ReverseExampleAsync(conversationId, message, cancellationToken); + case "safety check": return this.SafetyCheckExampleAsync(conversationId, message, cancellationToken); + case "markdown sample": return this.MarkdownExampleAsync(conversationId, message, cancellationToken); + case "html sample": return this.HTMLExampleAsync(conversationId, message, cancellationToken); + case "code sample": return this.CodeExampleAsync(conversationId, message, cancellationToken); + case "json sample": return this.JSONExampleAsync(conversationId, message, cancellationToken); + case "mermaid sample": return this.MermaidExampleAsync(conversationId, message, cancellationToken); + case "music sample": return this.MusicExampleAsync(conversationId, message, cancellationToken); + case "none": return this.NoneExampleAsync(conversationId, message, cancellationToken); + default: return this.NoneExampleAsync(conversationId, message, cancellationToken); + } + } + + // Check text with Azure Content Safety + private async Task<(bool isSafe, object report)> IsSafeAsync( + string? text, + CancellationToken cancellationToken) + { + Response? result = await this._contentSafety.AnalyzeTextAsync(text, cancellationToken).ConfigureAwait(false); + + bool isSafe = result.HasValue && result.Value.CategoriesAnalysis.All(x => x.Severity is 0); + IEnumerable report = result.HasValue ? result.Value.CategoriesAnalysis.Select(x => $"{x.Category}: {x.Severity}") : Array.Empty(); + + return (isSafe, report); + } + + private async Task EchoExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Ignore empty messages + if (string.IsNullOrWhiteSpace(message.Content)) { return; } + + // Create the answer content + var (inputIsSafe, report) = await this.IsSafeAsync(message.Content, cancellationToken).ConfigureAwait(false); + var answer = inputIsSafe + ? Message.CreateChatMessage(this.Id, message.Content) + : Message.CreateChatMessage(this.Id, "I'm not sure how to respond to that.", report); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ReverseExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Ignore empty messages + if (string.IsNullOrWhiteSpace(message.Content)) { return; } + + // Create the answer content + var (inputIsSafe, report) = await this.IsSafeAsync(message.Content, cancellationToken).ConfigureAwait(false); + var answer = inputIsSafe + ? Message.CreateChatMessage(this.Id, $"{new string(message.Content.Reverse().ToArray())}") + : Message.CreateChatMessage(this.Id, "I'm not sure how to respond to that.", report); + + // Check the output too + var (outputIsSafe, reportOut) = await this.IsSafeAsync(answer.Content, cancellationToken).ConfigureAwait(false); + if (!outputIsSafe) + { + answer = Message.CreateChatMessage(this.Id, "Sorry I won't process that.", reportOut); + } + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private Task LogChatHistoryAsInsight( + Conversation conversation, + CancellationToken cancellationToken) + { + var insight = new Insight("history", "Chat History", conversation.ToHtmlString(this.Id)); + return this.SetConversationInsightAsync(conversation.Id, insight, cancellationToken); + } + + private async Task SafetyCheckExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Create the answer content + Message answer; + Response? result = await this._contentSafety.AnalyzeTextAsync(message.Content, cancellationToken).ConfigureAwait(false); + if (!result.HasValue) + { + answer = Message.CreateChatMessage( + this.Id, + "Sorry, something went wrong, I couldn't analyze the message.", + "The request to Azure Content Safety failed and returned NULL"); + } + else + { + bool isOffensive = result.Value.CategoriesAnalysis.Any(x => x.Severity is > 0); + IEnumerable report = result.Value.CategoriesAnalysis.Select(x => $"{x.Category}: {x.Severity}"); + + answer = Message.CreateChatMessage( + this.Id, + isOffensive ? "Offensive content detected" : "OK", + report); + } + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MarkdownExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Prepare answer using Markdown syntax + const string MarkdownContent = """ + # Using Semantic Workbench with .NET Agents + + This project provides an example of testing your agent within the **Semantic Workbench**. + + ## Project Overview + + The sample project utilizes the `WorkbenchConnector` library, enabling you to focus on agent development and testing. + + Semantic Workbench allows mixing agents from different frameworks and multiple instances of the same agent. + The connector can manage multiple agent instances if needed, or you can work with a single instance if preferred. + To integrate agents developed with other frameworks, we recommend isolating each agent type with a dedicated web service, ie a dedicated project. + """; + var answer = Message.CreateChatMessage(this.Id, MarkdownContent); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task HTMLExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Create the answer content + const string HTMLExample = """ +

Using Semantic Workbench with .NET Agents

+ +

This project provides an example of testing your agent within the Semantic Workbench.

+ +

Project Overview

+ +

The sample project utilizes the

WorkbenchConnector
library, enabling you to focus on agent development and testing.

+ +

Semantic Workbench allows mixing agents from different frameworks and multiple instances of the same agent. + The connector can manage multiple agent instances if needed, or you can work with a single instance if preferred. + To integrate agents developed with other frameworks, we recommend isolating each agent type with a dedicated web service, ie a dedicated project.

+ """; + var answer = Message.CreateChatMessage(this.Id, HTMLExample, contentType: "text/html"); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task CodeExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Create the answer content + const string CodeExample = """ + How to instantiate SK with OpenAI: + + ```csharp + // Semantic Kernel with OpenAI + var openAIKey = appBuilder.Configuration.GetSection("OpenAI").GetValue("ApiKey") + ?? throw new ArgumentNullException("OpenAI config not found"); + var openAIModel = appBuilder.Configuration.GetSection("OpenAI").GetValue("Model") + ?? throw new ArgumentNullException("OpenAI config not found"); + appBuilder.Services.AddSingleton(_ => Kernel.CreateBuilder() + .AddOpenAIChatCompletion(openAIModel, openAIKey) + .Build()); + ``` + """; + var answer = Message.CreateChatMessage(this.Id, CodeExample); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MermaidExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Create the answer content + const string MermaidContentExample = """ + ```mermaid + gitGraph: + commit "Ashish" + branch newbranch + checkout newbranch + commit id:"1111" + commit tag:"test" + checkout main + commit type: HIGHLIGHT + commit + merge newbranch + commit + branch b2 + commit + ``` + """; + var answer = Message.CreateChatMessage(this.Id, MermaidContentExample); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MusicExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Create the answer content + const string ABCContentExample = """ + ```abc + X:1 + T:Twinkle, Twinkle, Little Star + M:4/4 + L:1/4 + K:C + C C G G | A A G2 | F F E E | D D C2 | + G G F F | E E D2 | G G F F | E E D2 | + C C G G | A A G2 | F F E E | D D C2 | + ``` + """; + var answer = Message.CreateChatMessage(this.Id, ABCContentExample); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task JSONExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Ignore empty messages + if (string.IsNullOrWhiteSpace(message.Content)) { return; } + + // Create the answer content + const string JSONExample = """ + { + "name": "Devis", + "age": 30, + "email": "noreply@some.email", + "address": { + "street": "123 Main St", + "city": "Anytown", + "state": "CA", + "zip": "123456" + } + } + """; + var answer = Message.CreateChatMessage(this.Id, JSONExample, contentType: "application/json"); + + // Update the chat history to include the outgoing message + this.Log.LogTrace("Store new message"); + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + this.Log.LogTrace("Send new message"); + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task NoneExampleAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + var conversation = await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Exit without doing anything + } + finally + { + this.Log.LogTrace("Reset agent status"); + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/examples/dotnet-example02/MyAgentConfig.cs b/examples/dotnet-example02/MyAgentConfig.cs new file mode 100644 index 00000000..4a8548fc --- /dev/null +++ b/examples/dotnet-example02/MyAgentConfig.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample02; + +public class MyAgentConfig : IAgentConfig +{ + [JsonPropertyName(nameof(this.ReplyToAgents))] + [JsonPropertyOrder(10)] + public bool ReplyToAgents { get; set; } = false; + + [JsonPropertyName(nameof(this.CommandsEnabled))] + [JsonPropertyOrder(20)] + public bool CommandsEnabled { get; set; } = false; + + [JsonPropertyName(nameof(this.Behavior))] + [JsonPropertyOrder(30)] + public string Behavior { get; set; } = "none"; + + public void Update(object? config) + { + if (config == null) + { + throw new ArgumentException("Incompatible or empty configuration"); + } + + if (config is not MyAgentConfig cfg) + { + throw new ArgumentException("Incompatible configuration type"); + } + + this.ReplyToAgents = cfg.ReplyToAgents; + this.CommandsEnabled = cfg.CommandsEnabled; + this.Behavior = cfg.Behavior; + } + + public object ToWorkbenchFormat() + { + Dictionary result = new(); + Dictionary defs = new(); + Dictionary properties = new(); + Dictionary jsonSchema = new(); + Dictionary uiSchema = new(); + + properties[nameof(this.ReplyToAgents)] = new Dictionary + { + { "type", "boolean" }, + { "title", "Reply to other assistants in conversations" }, + { "description", "Reply to assistants" }, + { "default", false } + }; + + properties[nameof(this.CommandsEnabled)] = new Dictionary + { + { "type", "boolean" }, + { "title", "Support commands" }, + { "description", "Support commands, e.g. /say" }, + { "default", false } + }; + + properties[nameof(this.Behavior)] = new Dictionary + { + { "type", "string" }, + { "default", "echo" }, + { "enum", new[] { "echo", "reverse", "safety check", "markdown sample", "code sample", "json sample", "mermaid sample", "html sample", "music sample", "none" } }, + { "title", "How to reply" }, + { "description", "How to reply to messages, what logic to use." }, + }; + + // Use "list of radio buttons" instead of default "select box" + uiSchema[nameof(this.Behavior)] = new Dictionary + { + { "ui:widget", "radio" } + }; + + jsonSchema["type"] = "object"; + jsonSchema["title"] = "ConfigStateModel"; + jsonSchema["additionalProperties"] = false; + jsonSchema["properties"] = properties; + jsonSchema["$defs"] = defs; + + result["json_schema"] = jsonSchema; + result["ui_schema"] = uiSchema; + result["config"] = this; + + return result; + } +} diff --git a/examples/dotnet-example02/MyWorkbenchConnector.cs b/examples/dotnet-example02/MyWorkbenchConnector.cs new file mode 100644 index 00000000..9f9c0931 --- /dev/null +++ b/examples/dotnet-example02/MyWorkbenchConnector.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample02; + +public sealed class MyWorkbenchConnector : WorkbenchConnector +{ + private readonly MyAgentConfig _defaultAgentConfig = new(); + private readonly IServiceProvider _sp; + + public MyWorkbenchConnector( + IServiceProvider sp, + IConfiguration appConfig, + IAgentServiceStorage storage, + ILoggerFactory? loggerFactory = null) + : base(appConfig, storage, loggerFactory?.CreateLogger() ?? new NullLogger()) + { + appConfig.GetSection("Agent").Bind(this._defaultAgentConfig); + this._sp = sp; + } + + /// + public override async Task CreateAgentAsync( + string agentId, + string? name, + object? configData, + CancellationToken cancellationToken = default) + { + if (this.GetAgent(agentId) != null) { return; } + + this.Log.LogDebug("Creating agent '{0}'", agentId); + + MyAgentConfig config = this._defaultAgentConfig; + if (configData != null) + { + var newCfg = JsonSerializer.Deserialize(JsonSerializer.Serialize(configData)); + if (newCfg != null) { config = newCfg; } + } + + // Instantiate using .NET Service Provider so that dependencies are automatically injected + var agent = ActivatorUtilities.CreateInstance(this._sp, agentId, name ?? agentId, config); + + await agent.StartAsync(cancellationToken).ConfigureAwait(false); + this.Agents.TryAdd(agentId, agent); + } +} diff --git a/examples/dotnet-example02/Program.cs b/examples/dotnet-example02/Program.cs new file mode 100644 index 00000000..c1a7f000 --- /dev/null +++ b/examples/dotnet-example02/Program.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure; +using Azure.AI.ContentSafety; +using Azure.Identity; +using Microsoft.SemanticKernel; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample02; + +internal static class Program +{ + private const string CORSPolicyName = "MY-CORS"; + + internal static async Task Main(string[] args) + { + // Setup + var appBuilder = WebApplication.CreateBuilder(args); + + // Load settings from files and env vars + appBuilder.Configuration + .AddJsonFile("appsettings.json") + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables(); + + // Storage layer to persist agents configuration and conversations + appBuilder.Services.AddSingleton(); + + // Agent service to support multiple agent instances + appBuilder.Services.AddSingleton(); + + // Azure AI Content Safety, used for demo + var azureContentSafetyAuthType = appBuilder.Configuration.GetSection("AzureContentSafety").GetValue("AuthType"); + var azureContentSafetyEndpoint = appBuilder.Configuration.GetSection("AzureContentSafety").GetValue("Endpoint"); + var azureContentSafetyApiKey = appBuilder.Configuration.GetSection("AzureContentSafety").GetValue("ApiKey"); + appBuilder.Services.AddSingleton(_ => azureContentSafetyAuthType == "AzureIdentity" + ? new ContentSafetyClient(new Uri(azureContentSafetyEndpoint!), new DefaultAzureCredential()) + : new ContentSafetyClient(new Uri(azureContentSafetyEndpoint!), new AzureKeyCredential(azureContentSafetyApiKey!))); + + // Misc + appBuilder.Services.AddLogging() + .AddCors(opt => opt.AddPolicy(CORSPolicyName, pol => pol.WithMethods("GET", "POST", "PUT", "DELETE"))); + + // Build + WebApplication app = appBuilder.Build(); + app.UseCors(CORSPolicyName); + + // Connect to workbench backend, keep alive, and accept incoming requests + var connectorEndpoint = app.Configuration.GetSection("Workbench").Get()!.ConnectorEndpoint; + using var agentService = app.UseAgentWebservice(connectorEndpoint, true); + await agentService.ConnectAsync().ConfigureAwait(false); + + // Start app and webservice + await app.RunAsync().ConfigureAwait(false); + } +} diff --git a/examples/dotnet-example02/README.md b/examples/dotnet-example02/README.md new file mode 100644 index 00000000..29537665 --- /dev/null +++ b/examples/dotnet-example02/README.md @@ -0,0 +1,50 @@ +# Example 2 - Content Types, Content Safety, Debugging + +This project provides an example of an agent with a configurable behavior, showing also Semantic Workbench support for **multiple content types**, such as Markdown, HTML, Mermaid graphs, JSON, etc. + +The agent demonstrates also a simple **integration with [Azure AI Content Safety](https://azure.microsoft.com/products/ai-services/ai-content-safety)**, to test user input and LLM models output. + +The example shows also how to leverage Semantic Workbench UI to **inspect agents' result, by including debugging information** readily available in the conversation. + +## Project Overview + +The sample project utilizes the `WorkbenchConnector` library, enabling you to focus on agent development and testing. + +Differently from [example 1](../dotnet-example01), this agent has a configurable `behavior` to show different output types. +All the logic starts from `MyAgent.ReceiveMessageAsync()` method as seen in the previous example. + +![Agent configuration](docs/config.png) + +## Agent output types + +* **echo**: echoes the user message back, only if the content is considered safe, after checking with Azure AI Content Safety. + +![Content Echo](docs/echo.png) + +* **reverse**: echoes the user message back, reversing the string, only if the content is considered safe, and only if the output is considered safe. + +![Reverse string](docs/reverse.png) + +* **safety check**: check if the user message is safe, returning debugging details. + +![Azure AI Content Safety check](docs/safety-check.png) + +* **markdown sample**: returns a fixed Markdown content example. + +![Markdown example](docs/markdown.png) + +* **code sample**: returns a fixed Code content example. + +![Code highlighting example](docs/code.png) + +* **json sample**: returns a fixed JSON content example. +* **mermaid sample**: returns a fixed [Mermaid Graph](https://mermaid.js.org/syntax/examples.html) example. + +![Mermaid graph example](docs/mermaid.png) + +* **html sample**: returns a fixed HTML content example. +* **music sample**: returns a fixed ABC Music example that can be played from the UI. + +![ABC music example](docs/abc.png) +* **none**: configures the agent not to reply to any message. + diff --git a/examples/dotnet-example02/appsettings.json b/examples/dotnet-example02/appsettings.json new file mode 100644 index 00000000..a2f3558a --- /dev/null +++ b/examples/dotnet-example02/appsettings.json @@ -0,0 +1,67 @@ +{ + // Semantic Workbench connector settings + "Workbench": { + // Semantic Workbench endpoint. + "WorkbenchEndpoint": "http://127.0.0.1:3000", + // The endpoint of your service, where semantic workbench will send communications too. + // This should match hostname, port, protocol and path of the web service. You can use + // this also to route semantic workbench through a proxy or a gateway if needed. + "ConnectorEndpoint": "http://127.0.0.1:9101/myagents", + // Unique ID of the service. Semantic Workbench will store this event to identify the server + // so you should keep the value fixed to match the conversations tracked across service restarts. + "ConnectorId": "AgentExample02", + // Name of your agent service + "ConnectorName": ".NET Multi Agent Service 02", + // Description of your agent service. + "ConnectorDescription": "Multi-agent service for .NET agents", + // Where to store agents settings and conversations + // See AgentServiceStorage class. + "StoragePathLinux": "/tmp/.sw/AgentExample02", + "StoragePathWindows": "$tmp\\.sw\\AgentExample02" + }, + // You agent settings + "Agent": { + "Name": "Agent2", + "ReplyToAgents": false, + "CommandsEnabled": true, + "Behavior": "none" + }, + // Azure Content Safety settings + "AzureContentSafety": { + "Endpoint": "https://....cognitiveservices.azure.com/", + "AuthType": "ApiKey", + "ApiKey": "..." + }, + // Web service settings + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://*:9101" + } + // "Https": { + // "Url": "https://*:9102" + // } + } + }, + // .NET Logger settings + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + }, + "Console": { + "LogToStandardErrorThreshold": "Critical", + "FormatterName": "simple", + "FormatterOptions": { + "TimestampFormat": "[HH:mm:ss.fff] ", + "SingleLine": true, + "UseUtcTimestamp": false, + "IncludeScopes": false, + "JsonWriterOptions": { + "Indented": true + } + } + } + } +} \ No newline at end of file diff --git a/examples/dotnet-example02/docs/abc.png b/examples/dotnet-example02/docs/abc.png new file mode 100644 index 00000000..59f88b85 Binary files /dev/null and b/examples/dotnet-example02/docs/abc.png differ diff --git a/examples/dotnet-example02/docs/code.png b/examples/dotnet-example02/docs/code.png new file mode 100644 index 00000000..d9537999 Binary files /dev/null and b/examples/dotnet-example02/docs/code.png differ diff --git a/examples/dotnet-example02/docs/config.png b/examples/dotnet-example02/docs/config.png new file mode 100644 index 00000000..3ddb47f9 Binary files /dev/null and b/examples/dotnet-example02/docs/config.png differ diff --git a/examples/dotnet-example02/docs/echo.png b/examples/dotnet-example02/docs/echo.png new file mode 100644 index 00000000..503ede4d Binary files /dev/null and b/examples/dotnet-example02/docs/echo.png differ diff --git a/examples/dotnet-example02/docs/markdown.png b/examples/dotnet-example02/docs/markdown.png new file mode 100644 index 00000000..34dcb252 Binary files /dev/null and b/examples/dotnet-example02/docs/markdown.png differ diff --git a/examples/dotnet-example02/docs/mermaid.png b/examples/dotnet-example02/docs/mermaid.png new file mode 100644 index 00000000..07b602bd Binary files /dev/null and b/examples/dotnet-example02/docs/mermaid.png differ diff --git a/examples/dotnet-example02/docs/reverse.png b/examples/dotnet-example02/docs/reverse.png new file mode 100644 index 00000000..d3a2f2ec Binary files /dev/null and b/examples/dotnet-example02/docs/reverse.png differ diff --git a/examples/dotnet-example02/docs/safety-check.png b/examples/dotnet-example02/docs/safety-check.png new file mode 100644 index 00000000..c1b8f190 Binary files /dev/null and b/examples/dotnet-example02/docs/safety-check.png differ