diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs new file mode 100644 index 00000000..9470e1c9 --- /dev/null +++ b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.Json; +using Discord; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Connector; +using Microsoft.Agents.Core.Models; +using Microsoft.Agents.Core.Serialization; +using Microsoft.Extensions.Logging; +using IActivity = Microsoft.Agents.Core.Models.IActivity; + +namespace AgentFrameworkWeather.Adapters +{ + /// + /// A custom for Discord. + /// + /// Discord has no built-in Azure Bot Service channel, so this adapter is the translation + /// layer between the Discord gateway and the Agents SDK, exactly like the SDK's A2AAdapter: + /// * INBOUND - turns a Discord message into an Activity + /// and calls , which drives the shared + /// WeatherAgent (AgentApplication) through the SDK turn pipeline. + /// * OUTBOUND - renders the agent's reply Activity as a + /// Discord embed and posts it back to the originating channel. + /// + /// The same WeatherAgent is reused unchanged; only this adapter differs from Slack/Teams. + /// + public class DiscordAdapter(ILogger logger, IChannelServiceClientFactory channelServiceClientFactory) : ChannelAdapter(logger) + { + /// Discord's channel id used on the Activity. + public const string ChannelId = "discord"; + + private readonly ILogger _logger = logger; + private readonly IChannelServiceClientFactory _channelServiceClientFactory = channelServiceClientFactory; + + // Maps an Activity conversation id (the Discord channel id) to the live Discord channel, + // so SendActivitiesAsync knows where to post the reply. + private readonly ConcurrentDictionary _channels = new(); + + /// + /// Registers the Discord channel for a conversation so replies can be routed back to it. + /// Called by the gateway service when an inbound message arrives. + /// + public void RegisterChannel(string conversationId, IMessageChannel channel) + => _channels[conversationId] = channel; + + /// + /// INBOUND: run one agent turn for the given activity. Identical shape to A2AAdapter - + /// build a TurnContext and run the SDK pipeline (which invokes the agent's OnTurnAsync). + /// + public override async Task ProcessActivityAsync( + ClaimsIdentity claimsIdentity, + IActivity activity, + AgentCallbackHandler callback, + CancellationToken cancellationToken) + { + await RunPipelineWithUserTokenAsync(claimsIdentity, activity, callback, cancellationToken).ConfigureAwait(false); + return null!; + } + + /// + /// PROACTIVE: after a sign-in completes, the SDK re-runs the original ("banked") activity via + /// this method. It must set up the same IUserTokenClient as ProcessActivityAsync, otherwise the + /// resumed turn (which reads the user token) fails with "IUserTokenClient is not available". + /// + public override Task ProcessProactiveAsync( + ClaimsIdentity claimsIdentity, + IActivity continuationActivity, + string audience, + AgentCallbackHandler callback, + CancellationToken cancellationToken) + => RunPipelineWithUserTokenAsync(claimsIdentity, continuationActivity, callback, cancellationToken); + + /// + /// Build a TurnContext, attach an IUserTokenClient (Discord isn't an Azure Bot channel, so the + /// base ChannelAdapter doesn't create one), and run the SDK turn pipeline. Used by both the + /// inbound and proactive (post-sign-in re-run) paths so the OAuth/OBO flow works over Discord. + /// + private async Task RunPipelineWithUserTokenAsync( + ClaimsIdentity claimsIdentity, + IActivity activity, + AgentCallbackHandler callback, + CancellationToken cancellationToken) + { + var context = new TurnContext(this, activity, claimsIdentity); + + using var userTokenClient = await _channelServiceClientFactory + .CreateUserTokenClientAsync(claimsIdentity, useAnonymous: null, cancellationToken).ConfigureAwait(false); + context.Services.Set(userTokenClient); + context.Services.Set(_channelServiceClientFactory); + + await RunPipelineAsync(context, callback, cancellationToken).ConfigureAwait(false); + } + + /// + /// OUTBOUND: render each reply message as a Discord embed and post it to the channel. + /// Sign-in (OAuthCard) activities are rendered as a clickable sign-in link. + /// + public override async Task SendActivitiesAsync( + ITurnContext turnContext, + IActivity[] activities, + CancellationToken cancellationToken) + { + var responses = new List(); + + foreach (var activity in activities) + { + // OAuth sign-in: Discord isn't a known Bot Service channel, so the OAuthCard carries + // no usable link. Fetch the real sign-in URL and post it as a Discord message. + if (TryGetOAuthConnectionName(activity, out var connectionName)) + { + responses.Add(await SendSignInAsync(turnContext, connectionName, cancellationToken).ConfigureAwait(false)); + continue; + } + + // Only render actual messages; skip typing/informative activities. + if (activity.Type != ActivityTypes.Message || string.IsNullOrWhiteSpace(activity.Text)) + { + responses.Add(new ResourceResponse()); + continue; + } + + if (!TryGetChannel(activity, turnContext, out var channel)) + { + responses.Add(new ResourceResponse()); + continue; + } + + // Discord embed description limit is 4096; trim defensively. + var body = activity.Text; + if (body.Length > 4000) + { + body = body[..4000] + "…"; + } + + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithDescription(body) + .WithFooter("Agent Framework + WorkIQ · Discord ChannelAdapter") + .WithCurrentTimestamp() + .Build(); + + var sent = await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + responses.Add(new ResourceResponse { Id = sent.Id.ToString() }); + } + + return [.. responses]; + } + + /// Resolve the live Discord channel for the conversation the activity belongs to. + private bool TryGetChannel(IActivity activity, ITurnContext turnContext, out IMessageChannel channel) + { + var conversationId = activity.Conversation?.Id ?? turnContext.Activity.Conversation?.Id; + if (conversationId == null || !_channels.TryGetValue(conversationId, out channel!)) + { + _logger.LogWarning("No Discord channel registered for conversation {ConversationId}", conversationId); + channel = null!; + return false; + } + return true; + } + + /// + /// Post the OAuth sign-in link to Discord. The IUserTokenClient (set on the turn) resolves the + /// real sign-in URL from the Bot Framework Token Service; the user clicks it, signs in, and + /// pastes the returned code back into the chat to complete the flow. + /// + private async Task SendSignInAsync(ITurnContext turnContext, string connectionName, CancellationToken cancellationToken) + { + if (!TryGetChannel(turnContext.Activity, turnContext, out var channel)) + { + return new ResourceResponse(); + } + + var userTokenClient = turnContext.Services.Get(); + var signInResource = await userTokenClient + .GetSignInResourceAsync(connectionName, turnContext.Activity, null, cancellationToken).ConfigureAwait(false); + var link = signInResource?.SignInLink; + if (string.IsNullOrEmpty(link)) + { + _logger.LogWarning("[Discord] No sign-in link available for connection {Connection}", connectionName); + return new ResourceResponse(); + } + + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithTitle("Sign in required") + .WithDescription($"Please [sign in here]({link}) to continue, then paste the code you receive back into this chat.") + .WithFooter("Agent Framework + WorkIQ · Discord ChannelAdapter") + .WithCurrentTimestamp() + .Build(); + + var sent = await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + return new ResourceResponse { Id = sent.Id.ToString() }; + } + + /// Detect an OAuthCard attachment and return its OAuth connection name. + private static bool TryGetOAuthConnectionName(IActivity activity, out string connectionName) + { + connectionName = null!; + if (activity.Attachments is null) + { + return false; + } + + foreach (var attachment in activity.Attachments) + { + if (!string.Equals(attachment.ContentType, "application/vnd.microsoft.card.oauth", StringComparison.OrdinalIgnoreCase) + || attachment.Content is null) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(ProtocolJsonSerializer.ToJson(attachment.Content)); + if (doc.RootElement.TryGetProperty("connectionName", out var cn)) + { + connectionName = cn.GetString()!; + if (!string.IsNullOrEmpty(connectionName)) + { + return true; + } + } + } + catch (JsonException) + { + // Not a parseable OAuthCard; ignore. + } + } + + return false; + } + } +} diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs new file mode 100644 index 00000000..afaf1788 --- /dev/null +++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Claims; +using Discord; +using Discord.WebSocket; +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgentFrameworkWeather.Adapters +{ + /// + /// Drives the from the Discord gateway. + /// + /// Discord is WebSocket/event driven (not HTTP), so instead of an /api/messages endpoint we + /// subscribe to the gateway's MessageReceived event, translate each message into an Activity, + /// and call to run the shared WeatherAgent. + /// + /// Only starts when a Discord bot token is configured ("Discord:BotToken" or DISCORD_BOT_TOKEN). + /// + public class DiscordGatewayService( + IConfiguration configuration, + DiscordAdapter adapter, + IServiceProvider services, + ILogger logger) : BackgroundService + { + private DiscordSocketClient? _client; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var token = configuration["Discord:BotToken"] + ?? Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogInformation("Discord bot token not configured - Discord channel disabled."); + return; + } + + _client = new DiscordSocketClient(new DiscordSocketConfig + { + GatewayIntents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.DirectMessages | + GatewayIntents.MessageContent, + LogLevel = LogSeverity.Info + }); + + _client.Log += msg => + { + logger.LogInformation("[Discord] {Message}", msg.ToString()); + return Task.CompletedTask; + }; + + _client.Ready += () => + { + logger.LogInformation("Discord bot ready as {User}", _client.CurrentUser); + // Pre-load WorkIQ MCP tools in the background so the first real message is fast. + _ = Task.Run(WarmUpToolsAsync); + return Task.CompletedTask; + }; + + // Offload processing to a background task so a slow agent turn (several seconds while + // WorkIQ MCP tools load and the model runs) does not block the Discord gateway heartbeat. + // Discord.Net warns "A MessageReceived handler is blocking the gateway task" otherwise. + _client.MessageReceived += msg => + { + _ = Task.Run(() => OnDiscordMessageAsync(msg)); + return Task.CompletedTask; + }; + + await _client.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); + await _client.StartAsync().ConfigureAwait(false); + + // Keep the service alive until shutdown. + await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }).ConfigureAwait(false); + } + + /// + /// Pre-load the WorkIQ MCP tools at startup so the first real Discord message is fast. Sends a + /// synthetic "warmup" Event activity through the adapter pipeline (which builds a full TurnContext) + /// and lets the agent populate its shared tool cache. Best-effort: failures fall back to lazy load. + /// + private async Task WarmUpToolsAsync() + { + try + { + var activity = new Activity + { + Type = ActivityTypes.Event, + Name = "warmup", + ChannelId = DiscordAdapter.ChannelId, + ServiceUrl = "discord", + Conversation = new ConversationAccount { Id = "warmup" }, + From = new ChannelAccount { Id = "warmup" }, + }; + + using var scope = services.CreateScope(); + var agent = scope.ServiceProvider.GetRequiredService(); + await adapter.ProcessActivityAsync(CreateBotClaimsIdentity(), activity, agent.OnTurnAsync, CancellationToken.None) + .ConfigureAwait(false); + logger.LogInformation("Discord: WorkIQ MCP tools warm-up complete."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Discord: WorkIQ MCP tools warm-up failed (tools will load on first message)."); + } + } + + private async Task OnDiscordMessageAsync(SocketMessage message) + { + if (message is not SocketUserMessage userMessage) return; + if (message.Author.IsBot) return; + + var client = _client; + if (client is null) return; + + var text = message.Content?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(text)) return; + + var conversationId = message.Channel.Id.ToString(); + + // Tell the adapter which live Discord channel this conversation maps to (for replies). + adapter.RegisterChannel(conversationId, message.Channel); + + // Translate the Discord message into an Activity the agent understands. + var activity = new Activity + { + Type = ActivityTypes.Message, + Id = message.Id.ToString(), + Text = text, + ChannelId = DiscordAdapter.ChannelId, + ServiceUrl = "discord", + Conversation = new ConversationAccount { Id = conversationId }, + From = new ChannelAccount { Id = message.Author.Id.ToString(), Name = message.Author.Username }, + Recipient = new ChannelAccount { Id = client.CurrentUser.Id.ToString(), Name = client.CurrentUser.Username }, + }; + + var identity = CreateBotClaimsIdentity(); + + try + { + using (message.Channel.EnterTypingState()) + using (var scope = services.CreateScope()) + { + var agent = scope.ServiceProvider.GetRequiredService(); + await adapter.ProcessActivityAsync(identity, activity, agent.OnTurnAsync, CancellationToken.None) + .ConfigureAwait(false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing Discord message"); + await message.Channel.SendMessageAsync("Sorry, I hit an error while processing that. 🐾") + .ConfigureAwait(false); + } + } + + // Build a non-anonymous ClaimsIdentity carrying the Azure Bot's app id so the adapter can + // create an IUserTokenClient (the OAuth sign-in flow needs the bot app id to call the + // Bot Framework Token Service). Falls back to an empty identity if not configured. + private ClaimsIdentity CreateBotClaimsIdentity() + { + var botAppId = configuration["Connections:BotServiceConnection:Settings:ClientId"]; + return string.IsNullOrEmpty(botAppId) + ? new ClaimsIdentity() + : AgentClaims.CreateIdentity(botAppId, appId: botAppId); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_client != null) + { + await _client.StopAsync().ConfigureAwait(false); + await _client.LogoutAsync().ConfigureAwait(false); + } + await base.StopAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index 6cfaf357..08d6b68f 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using AgentFrameworkWeather.Adapters; using AgentFrameworkWeather.Tools; using Microsoft.Agents.A365.Runtime.Utils; using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; @@ -75,13 +76,40 @@ public WeatherAgent( // Read auth handler names from configuration (can be empty/null to disable). AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); - OboAuthHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName"); + OboAuthHandlerName = _configuration.GetValue("AgentApplication:UserAuthorization:Handlers:mcs:Settings:AzureBotOAuthConnectionName"); // Greet when members are added to the conversation OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + // Background warm-up: an Event activity named "warmup" pre-loads the WorkIQ MCP tools + // into the shared cache (e.g. sent by the Discord host at startup) so the first real + // message is fast. Registered before the generic message handler. + OnActivity(ActivityTypes.Event, OnWarmupAsync); + + OnMessage("ForceLogout", OnLogout); + // Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS - OnActivity(ActivityTypes.Message, OnMessageAsync); + OnActivity(ActivityTypes.Message, OnMessageAsync, autoSignInHandlers: [OboAuthHandlerName]); + } + + private async Task OnLogout(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await UserAuthorization.SignOutUserAsync(turnContext, turnState); + } + + /// + /// Handle the "warmup" Event activity: pre-load WorkIQ MCP tools into the cache and return + /// without replying. Ignores any other Event activity. + /// + private async Task OnWarmupAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + if (!string.Equals(turnContext.Activity.Name, "warmup", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _logger?.LogInformation("Warm-up: pre-loading WorkIQ MCP tools for channel {Channel}.", turnContext.Activity.ChannelId); + await WarmUpWorkIqToolsAsync(turnContext).ConfigureAwait(false); } /// @@ -123,13 +151,21 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta // Slack: render the response as native Slack Blocks (a card) instead of the // streamed Bot Framework text. Other channels keep the streaming experience. bool isSlack = turnContext.Activity.ChannelId == Channels.Slack; + bool isDiscord = turnContext.Activity.ChannelId == DiscordAdapter.ChannelId; + + var userToken = await UserAuthorization.GetTurnTokenAsync(turnContext, OboAuthHandlerName); var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; // Pick the auth handler for this turn (agentic vs OBO). In dev the BEARER_TOKEN path is used. - string? toolAuthHandlerName = turnContext.Activity.IsAgenticRequest() - ? AgenticAuthHandlerName - : OboAuthHandlerName; + // Discord signs the user in (custom DiscordAdapter provides the IUserTokenClient), but the + // WorkIQ tools still use the dev bearer-token path here; wiring the tools to the user's OBO + // token is a follow-up. + string? toolAuthHandlerName = isDiscord + ? null + : turnContext.Activity.IsAgenticRequest() + ? AgenticAuthHandlerName + : OboAuthHandlerName; var _agent = await GetClientAgent(turnContext, turnState, toolAuthHandlerName); @@ -152,6 +188,28 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta return; } + // Discord: collect the full response and send it as ONE message activity; the + // DiscordAdapter renders it as an embed. (Streaming would create many partial messages.) + if (isDiscord) + { + var sb = new StringBuilder(); + await foreach (var response in _agent.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) + { + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + sb.Append(response.Text); + } + } + turnState.Conversation.SetValue("conversation.threadInfo", (await _agent.SerializeSessionAsync(thread)).ToString()); + + var answer = sb.ToString(); + if (!string.IsNullOrWhiteSpace(answer)) + { + await turnContext.SendActivityAsync(MessageFactory.Text(answer), cancellationToken).ConfigureAwait(false); + } + return; + } + // Non-Slack channels: stream the response back as it is produced. await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Just a moment please..").ConfigureAwait(false); try @@ -364,6 +422,14 @@ private async Task> GetWorkIqMcpToolsAsync(ITurnContext context, s } } + /// + /// Pre-load the WorkIQ MCP tools into the shared cache so the first real message does not pay + /// the ~5s load. Intended for background warm-up (e.g. Discord at startup) using the dev bearer + /// token. No-op if tools are already cached or no token is available; never sends a reply. + /// + private Task WarmUpWorkIqToolsAsync(ITurnContext context) + => GetWorkIqMcpToolsAsync(context, authHandlerName: null, announceLoading: false); + /// /// Extract the expiry (exp claim) from a JWT access token. Falls back to a short window /// if the token cannot be parsed, so a bad token never yields a long-lived cache entry. diff --git a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj index af1581d7..7f263530 100644 --- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj +++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj @@ -19,21 +19,23 @@ + + + - - + + - + - + diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index b92a1f5e..2d14e576 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -2,13 +2,16 @@ // Licensed under the MIT License. using AgentFrameworkWeather; +using AgentFrameworkWeather.Adapters; using AgentFrameworkWeather.Agent; using Azure; using Azure.AI.OpenAI; using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; using Microsoft.Agents.Hosting.AspNetCore; using Microsoft.Agents.Storage; using Microsoft.Agents.Storage.Transcript; @@ -46,6 +49,21 @@ // Add the bot (which is transient) builder.AddAgent(); +// AutoSignIn selector: run the OAuth sign-in flow only for user Message activities. This lets +// Slack AND Discord sign the user in on their first message, while skipping non-message turns +// such as the Discord startup "warmup" Event (which loads tools and must not prompt sign-in). +builder.Services.AddSingleton(_ => + (turnContext, cancellationToken) => + Task.FromResult(turnContext.Activity.Type == ActivityTypes.Message)); + +// ********** Discord channel (custom ChannelAdapter) ********** +// Discord has no Azure Bot Service channel, so we host it ourselves: a DiscordAdapter +// (ChannelAdapter) plus a background service that drives it from the Discord gateway. +// Reuses the SAME WeatherAgent as Slack/Teams. Only starts if Discord:BotToken is set. +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +// ********** END Discord channel ********** + // Register IChatClient with correct types builder.Services.AddSingleton(sp => { diff --git a/samples/dotnet/Agent Framework/appsettings.json b/samples/dotnet/Agent Framework/appsettings.json index 5cc6ac86..a303ff6f 100644 --- a/samples/dotnet/Agent Framework/appsettings.json +++ b/samples/dotnet/Agent Framework/appsettings.json @@ -7,17 +7,21 @@ "TenantId": "{{TenantId}}" }, - "AgentApplicationOptions": { +"AgentApplication": { "StartTypingTimer": true, "RemoveRecipientMention": false, - "NormalizeMentions": false - }, + "NormalizeMentions": false, - "AgentApplication": { - // WorkIQ MCP auth handler names. Leave empty for local dev (uses BEARER_TOKEN env var). - // Set these for production / Teams to use OBO / agentic token exchange. - "AgenticAuthHandlerName": "", - "OboAuthHandlerName": "" + "UserAuthorization": { + "DefaultHandlerName": "graph", + "Handlers": { + "mcs": { + "Settings": { + "AzureBotOAuthConnectionName": "graph" + } + } + } + } }, "Logging": {