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.
+
+
+
# 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 output types
+
+* **echo**: echoes the user message back, only if the content is considered safe, after checking with Azure AI Content Safety.
+
+
+
+* **reverse**: echoes the user message back, reversing the string, only if the content is considered safe, and only if the output is considered safe.
+
+
+
+* **safety check**: check if the user message is safe, returning debugging details.
+
+
+
+* **markdown sample**: returns a fixed Markdown content example.
+
+
+
+* **code sample**: returns a fixed Code content example.
+
+
+
+* **json sample**: returns a fixed JSON content example.
+* **mermaid sample**: returns a fixed [Mermaid Graph](https://mermaid.js.org/syntax/examples.html) example.
+
+
+
+* **html sample**: returns a fixed HTML content example.
+* **music sample**: returns a fixed ABC Music example that can be played from the UI.
+
+
+* **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