Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions dotnet/devin/sample-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Agent secrets and local overrides
appsettings.Development.json
appsettings.Local.json

# .NET build output
bin/
obj/
publish/

# User secrets
secrets.json

# IDE
.vs/
*.user
*.suo

# A365 CLI generated files (keep a365.config.json as template)
a365.generated.config.json
scope-update*.json
required-access.json

# Deployment artifacts
deploy.zip
*.zip
web.config

# Logs
*.log
log.txt
logs/

# Dev tools
devTools/

# Environment files
.env
.env.*
159 changes: 159 additions & 0 deletions dotnet/devin/sample-agent/Agent/MyAgent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Agent365DevinSampleAgent.Client;
using Agent365DevinSampleAgent.telemetry;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;
using System.Diagnostics;

namespace Agent365DevinSampleAgent.Agent;

/// <summary>
/// Devin Sample Agent — routes messages to the Devin API and returns responses.
/// Handles installation, notifications, typing indicators, and observability.
/// </summary>
public class MyAgent : AgentApplication
{
private const string AgentHireMessage = "Thank you for hiring me! Looking forward to assisting you in your professional journey!";
private const string AgentFarewellMessage = "Thank you for your time, I enjoyed working with you.";

private readonly DevinClient _devinClient;
private readonly ILogger<MyAgent> _logger;

public MyAgent(
AgentApplicationOptions options,
DevinClient devinClient,
ILogger<MyAgent> logger)
: base(options)
{
_devinClient = devinClient;
_logger = logger;

// Register message handler for agentic requests
OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true);

// Register message handler for non-agentic requests (Playground / WebChat)
OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false);

// Register installation handler for agentic requests
OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true);

// Register installation handler for non-agentic requests (Playground)
OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false);

// Register conversation update handler
OnConversationUpdate(ConversationUpdateEvents.MembersAdded, OnMembersAddedAsync);
}

private async Task OnMembersAddedAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
foreach (var member in turnContext.Activity.MembersAdded ?? [])
{
if (member.Id != turnContext.Activity.Recipient?.Id)
{
await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
}
}
}

private async Task OnInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
using var activity = AgentMetrics.InitializeMessageHandlingActivity("InstallationUpdate", turnContext);
var stopwatch = Stopwatch.StartNew();

try
{
if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add)
{
_logger.LogInformation("Agent installed");
await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
}
else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove)
{
_logger.LogInformation("Agent uninstalled");
await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken);
}

AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling installation update");
AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, false);
throw;
}
}

private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
using var activity = AgentMetrics.InitializeMessageHandlingActivity("MessageHandler", turnContext);
var stopwatch = Stopwatch.StartNew();

try
{
// Log user identity
var fromAccount = turnContext.Activity.From;
_logger.LogDebug(
"Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
fromAccount?.Name ?? "(unknown)",
fromAccount?.Id ?? "(unknown)",
fromAccount?.AadObjectId ?? "(none)");

var userMessage = turnContext.Activity.Text?.Trim();
if (string.IsNullOrEmpty(userMessage))
{
await turnContext.SendActivityAsync(
MessageFactory.Text("Please send me a message and I'll help you!"), cancellationToken);
Comment thread
Yogeshp-MSFT marked this conversation as resolved.
return;
}

// Send immediate acknowledgment (discrete Teams message)
await turnContext.SendActivityAsync(
MessageFactory.Text("Got it — working on it…"), cancellationToken);

// Typing indicator loop — refreshes every ~4s for long-running operations
using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var typingTask = Task.Run(async () =>
{
try
{
while (!typingCts.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token);
await turnContext.SendActivityAsync(
new Microsoft.Agents.Core.Models.Activity { Type = ActivityTypes.Typing }, typingCts.Token);
}
}
catch (OperationCanceledException) { /* expected on cancel */ }
}, typingCts.Token);

try
{
// Invoke Devin
var response = await _devinClient.InvokeAgentAsync(userMessage, cancellationToken);

// Send the Devin response as a discrete message
await turnContext.SendActivityAsync(
MessageFactory.Text(response), cancellationToken);
}
finally
{
// Stop typing indicator
await typingCts.CancelAsync();
try { await typingTask; } catch (OperationCanceledException) { }
}

AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing message");
AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, false);
await turnContext.SendActivityAsync(
MessageFactory.Text("There was an error processing your request."), cancellationToken);
}
}
}
161 changes: 161 additions & 0 deletions dotnet/devin/sample-agent/AspNetExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Agents.Authentication;
using Microsoft.Agents.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Validators;
using System.Collections.Concurrent;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;

namespace Agent365DevinSampleAgent;

public static class AspNetExtensions
{
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> _openIdMetadataCache = new();

/// <summary>
/// Adds token validation typical for ABS/SMBA and Bot-to-bot.
/// Default to Azure Public Cloud.
/// </summary>
public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation")
{
IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName);

if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true))
{
System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled");
return;
}

services.AddAgentAspNetAuthentication(tokenValidationSection.Get<TokenValidationOptions>()!);
}

public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions)
{
AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions));

if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0)
{
throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId");
}

foreach (var audience in validationOptions.Audiences)
{
if (!Guid.TryParse(audience, out _))
{
throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID");
}
}

if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0)
{
validationOptions.ValidIssuers =
[
"https://api.botframework.com",
"https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
"https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0",
"https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/",
"https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0",
"https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/",
"https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0",
];

if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _))
{
validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId));
validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId));
}
}

if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl))
{
validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl;
}

if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl))
{
validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl;
}

var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval;

_ = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
ValidIssuers = validationOptions.ValidIssuers,
ValidAudiences = validationOptions.Audiences,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
return GetSigningKeys(validationOptions, openIdMetadataRefresh, kid);
},
};
options.Authority = string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}", validationOptions.TenantId ?? "common");
});

services.AddAuthorization();
}

private static IEnumerable<SecurityKey> GetSigningKeys(TokenValidationOptions validationOptions, TimeSpan openIdMetadataRefresh, string kid)
{
var allKeys = new List<SecurityKey>();

var openIdUrls = new[]
{
validationOptions.OpenIdMetadataUrl!,
validationOptions.AzureBotServiceOpenIdMetadataUrl!,
};

foreach (var url in openIdUrls)
{
var configManager = _openIdMetadataCache.GetOrAdd(
url,
key => new ConfigurationManager<OpenIdConnectConfiguration>(
key,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever())
{
AutomaticRefreshInterval = openIdMetadataRefresh
});

try
{
var openIdConfig = configManager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult();
allKeys.AddRange(openIdConfig.SigningKeys);
}
catch (Exception)
{
// If one metadata endpoint fails, continue with others
}
}

return allKeys;
}
}

public class TokenValidationOptions
{
public bool Enabled { get; set; } = true;
public List<string>? Audiences { get; set; }
public string? TenantId { get; set; }
public List<string>? ValidIssuers { get; set; }
public string? OpenIdMetadataUrl { get; set; }
public string? AzureBotServiceOpenIdMetadataUrl { get; set; }
public TimeSpan? OpenIdMetadataRefresh { get; set; }
public bool IsGov { get; set; }
}
Loading
Loading