Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8a9aee7
initial commit
Mar 27, 2026
6a0f6bc
add dual model support - 5.4 and computer-use-preview
Mar 30, 2026
2581f90
fix nits
Mar 30, 2026
64b2121
add comment
Apr 2, 2026
7535418
Merge branch 'main' of https://github.com/microsoft/Agent365-Samples …
desmarest Apr 6, 2026
f11bfc3
feat: multi-session handling per conversation
Apr 6, 2026
78ddf83
Merge branch 'mabdelkader/cuaAgentSample' of https://github.com/Moham…
desmarest Apr 6, 2026
407b43c
allow ending sessions and conversational messages, fix nuget and pack…
Apr 6, 2026
030efb0
add per agent user onedrive creation
Apr 7, 2026
8d49310
feat: use previous_response_id to avoid resending screenshots in CUA …
desmarest Apr 7, 2026
f7446b4
Merge pull request #1 from desmarest/bertd/cua-previous-response-id
MohamedAbdekader Apr 8, 2026
9cb8229
fix: keep last screenshot pair between messages + session recovery fo…
desmarest Apr 8, 2026
322daf1
fix: use W365 session ID for OneDrive screenshot folder names
desmarest Apr 8, 2026
e548392
refactor: improve variable names in history pruning logic
desmarest Apr 8, 2026
09148bc
feat: multi-server MCP support and function tool integration
desmarest Apr 16, 2026
8d8f009
Merge pull request #3 from desmarest/bertd/latest-fixes
MohamedAbdekader Apr 17, 2026
5ea71c3
Restore OneDrive folder link, add mail tooling, logging, prompt tweaks
desmarest Apr 17, 2026
bcd331b
Merge remote-tracking branch 'myfork/mabdelkader/cuaAgentSample' into…
desmarest Apr 17, 2026
908ef9e
Revert "Merge remote-tracking branch 'myfork/mabdelkader/cuaAgentSamp…
desmarest Apr 17, 2026
e5a1895
Port EndSession + session recovery + packaging hygiene from mabdelkader
desmarest Apr 17, 2026
c189f13
Fix intermittent Kestrel "Reading is already in progress" crash
desmarest Apr 17, 2026
ce58c6f
Expand ToolingManifest to 10 MCP servers (mail/calendar/teams/odsp/etc.)
desmarest Apr 17, 2026
06795f0
Merge pull request #4 from MohamedAbdekader/users/bertd/multi-mcp-tools
MohamedAbdekader Apr 17, 2026
c1dd6ad
Port V2 sample, fix Error sentinel, per-prompt screenshot folders
desmarest May 2, 2026
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
25 changes: 25 additions & 0 deletions dotnet/w365-computer-use/W365ComputerUseSample.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36623.8
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "W365ComputerUseSample", "sample-agent\W365ComputerUseSample.csproj", "{B72D1A3E-4F8C-9E56-A1B2-C3D4E5F60789}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B72D1A3E-4F8C-9E56-A1B2-C3D4E5F60789}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B72D1A3E-4F8C-9E56-A1B2-C3D4E5F60789}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B72D1A3E-4F8C-9E56-A1B2-C3D4E5F60789}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B72D1A3E-4F8C-9E56-A1B2-C3D4E5F60789}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F90}
EndGlobalSection
EndGlobal
2 changes: 2 additions & 0 deletions dotnet/w365-computer-use/sample-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
appsettings.Development.json
Screenshots/
342 changes: 342 additions & 0 deletions dotnet/w365-computer-use/sample-agent/Agent/MyAgent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net.Http.Headers;
using W365ComputerUseSample.ComputerUse;
using W365ComputerUseSample.Telemetry;
using Microsoft.Agents.A365.Observability.Caching;
using Microsoft.Agents.A365.Runtime.Utils;
using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core;
using Microsoft.Agents.Core.Models;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;

namespace W365ComputerUseSample.Agent;

public class MyAgent : AgentApplication
{
private const string AgentWelcomeMessage = "Hello! I can help you perform tasks on a Windows 365 Cloud PC. Tell me what you'd like to do.";
private const string AgentHireMessage = "Thank you for hiring me! I can control a Windows desktop to accomplish tasks for you.";
private const string AgentFarewellMessage = "Thank you for your time, I enjoyed working with you.";

private readonly IExporterTokenCache<AgenticTokenStruct>? _agentTokenCache;
private readonly ILogger<MyAgent> _logger;
private readonly IMcpToolRegistrationService _toolService;
private readonly ComputerUseOrchestrator _orchestrator;
private readonly string? _mcpServerUrl;

private readonly string? AgenticAuthHandlerName;
private readonly string? OboAuthHandlerName;

/// <summary>
/// Check if a bearer token is available in the environment for development/testing.
/// </summary>
public static bool TryGetBearerTokenForDevelopment(out string? bearerToken)
{
bearerToken = Environment.GetEnvironmentVariable("BEARER_TOKEN");
return !string.IsNullOrEmpty(bearerToken);
}

/// <summary>
/// Checks if graceful fallback is enabled when MCP tools fail to load.
/// Only allowed in Development + SKIP_TOOLING_ON_ERRORS=true.
/// </summary>
private static bool ShouldSkipToolingOnErrors()
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ??
"Production";

var skipToolingOnErrors = Environment.GetEnvironmentVariable("SKIP_TOOLING_ON_ERRORS");

return environment.Equals("Development", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(skipToolingOnErrors) &&
skipToolingOnErrors.Equals("true", StringComparison.OrdinalIgnoreCase);
}

public MyAgent(
AgentApplicationOptions options,
IConfiguration configuration,
IExporterTokenCache<AgenticTokenStruct> agentTokenCache,
IMcpToolRegistrationService toolService,
ComputerUseOrchestrator orchestrator,
ILogger<MyAgent> logger) : base(options)
{
_agentTokenCache = agentTokenCache;
_logger = logger;
_toolService = toolService;
_orchestrator = orchestrator;
_mcpServerUrl = configuration["McpServer:Url"];

AgenticAuthHandlerName = configuration.GetValue<string>("AgentApplication:AgenticAuthHandlerName");
OboAuthHandlerName = configuration.GetValue<string>("AgentApplication:OboAuthHandlerName");

// Greet when members are added
OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync);

// Compute auth handler arrays once
var agenticHandlers = !string.IsNullOrEmpty(AgenticAuthHandlerName) ? [AgenticAuthHandlerName] : Array.Empty<string>();
var oboHandlers = !string.IsNullOrEmpty(OboAuthHandlerName) ? [OboAuthHandlerName] : Array.Empty<string>();

// Handle install/uninstall
OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers);
OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false);

// Handle messages — MUST BE AFTER any other message handlers
OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers);
OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false, autoSignInHandlers: oboHandlers);
}

protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
await AgentMetrics.InvokeObservedAgentOperation(
"WelcomeMessage",
turnContext,
async () =>
{
foreach (ChannelAccount member in turnContext.Activity.MembersAdded)
{
if (member.Id != turnContext.Activity.Recipient.Id)
{
await turnContext.SendActivityAsync(AgentWelcomeMessage);
}
}
});
}

protected async Task OnInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
await AgentMetrics.InvokeObservedAgentOperation(
"InstallationUpdate",
turnContext,
async () =>
{
_logger.LogInformation(
"InstallationUpdate received — Action: '{Action}', DisplayName: '{Name}', UserId: '{Id}'",
turnContext.Activity.Action ?? "(none)",
turnContext.Activity.From?.Name ?? "(unknown)",
turnContext.Activity.From?.Id ?? "(unknown)");

if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add)
{
await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
}
else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove)
{
await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken);
}
});
}

protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
if (turnContext is null)
{
throw new ArgumentNullException(nameof(turnContext));
}

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)");

// Select auth handler based on request type
string? ObservabilityAuthHandlerName;
string? ToolAuthHandlerName;
if (turnContext.IsAgenticRequest())
{
ObservabilityAuthHandlerName = ToolAuthHandlerName = AgenticAuthHandlerName;
}
else
{
ObservabilityAuthHandlerName = ToolAuthHandlerName = OboAuthHandlerName;
}

await A365OtelWrapper.InvokeObservedAgentOperation(
"MessageProcessor",
turnContext,
turnState,
_agentTokenCache,
UserAuthorization,
ObservabilityAuthHandlerName ?? string.Empty,
_logger,
async () =>
{
// Immediate acknowledgment
await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken).ConfigureAwait(false);

// Typing indicator
await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), cancellationToken).ConfigureAwait(false);

using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var typingTask = Task.Run(async () =>
{
try
{
while (!typingCts.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token).ConfigureAwait(false);
await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), typingCts.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { /* expected */ }
}, typingCts.Token);

try
{
var userText = turnContext.Activity.Text?.Trim() ?? string.Empty;

// Get W365 MCP tools — direct connection in Dev, SDK in Production
var (w365Tools, mcpClient) = await GetW365ToolsAsync(turnContext, ToolAuthHandlerName);

try
{
if (w365Tools == null || w365Tools.Count == 0)
{
await turnContext.SendActivityAsync(
MessageFactory.Text("Unable to connect to the W365 Computer Use service. Please check your configuration."),
cancellationToken);
return;
}

await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Working on your request...").ConfigureAwait(false);

// Get Graph token for OneDrive screenshot upload.
// In production: acquired via agentic auth (UserAuthorization).
// In development: set GRAPH_TOKEN env var with a token that has Files.ReadWrite scope.
string? graphToken = null;
if (!string.IsNullOrEmpty(ToolAuthHandlerName))
{
graphToken = await UserAuthorization.GetTurnTokenAsync(turnContext, ToolAuthHandlerName);
}
if (string.IsNullOrEmpty(graphToken))
{
graphToken = Environment.GetEnvironmentVariable("GRAPH_TOKEN");
}

// Run the CUA loop — session is already active
var response = await _orchestrator.RunAsync(
userText,
w365Tools,
mcpClient: mcpClient,
graphAccessToken: graphToken,
onStatusUpdate: status => turnContext.StreamingResponse.QueueInformativeUpdateAsync(status).ConfigureAwait(false),
cancellationToken: cancellationToken);

// Send the response
turnContext.StreamingResponse.QueueTextChunk(response);
}
finally
{
// Don't dispose the MCP client — it's reused across messages and
// needed for EndSession on shutdown. It will be disposed with the app.
}
}
finally
{
typingCts.Cancel();
try { await typingTask.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected */ }
await turnContext.StreamingResponse.EndStreamAsync(cancellationToken).ConfigureAwait(false);
}
});
}

/// <summary>
/// Get the W365 MCP tools. In Development mode with a bearer token, connects directly
/// to the MCP server URL from appsettings.json. In Production, uses the A365 SDK
/// to discover servers via the Tooling Gateway.
/// </summary>
private async Task<(IList<AITool>? Tools, IMcpClient? Client)> GetW365ToolsAsync(ITurnContext context, string? authHandlerName)
{
// Acquire access token
string? accessToken = null;
string? agentId = null;

if (!string.IsNullOrEmpty(authHandlerName))
{
accessToken = await UserAuthorization.GetTurnTokenAsync(context, authHandlerName);
agentId = Utility.ResolveAgentIdentity(context, accessToken);
}
else if (TryGetBearerTokenForDevelopment(out var bearerToken))
{
_logger.LogInformation("Using bearer token from environment.");
accessToken = bearerToken;
agentId = Utility.ResolveAgentIdentity(context, accessToken!);
}

if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(agentId))
{
_logger.LogWarning("No auth token or agent identity available. Cannot connect to MCP.");
return (null, null);
}

try
{
// Development with bearer token: use orchestrator's cached MCP connection
if (TryGetBearerTokenForDevelopment(out _) && IsDevelopment())
{
if (string.IsNullOrEmpty(_mcpServerUrl))
throw new InvalidOperationException("McpServer:Url is required in appsettings.json for Development mode.");
return await _orchestrator.GetOrCreateMcpConnectionAsync(_mcpServerUrl, accessToken!);
}

// Production: use the A365 SDK's tooling gateway for server discovery
var handlerForMcp = !string.IsNullOrEmpty(authHandlerName)
? authHandlerName
: OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty;
var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null;

var allTools = await _toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false);

// Filter to only W365 tools
var w365Tools = FilterW365Tools(allTools);
return (w365Tools, null);
}
catch (Exception ex)
{
if (ShouldSkipToolingOnErrors())
{
_logger.LogWarning(ex, "Failed to connect to MCP servers. Continuing without tools (SKIP_TOOLING_ON_ERRORS=true).");
return (null, null);
}

_logger.LogError(ex, "Failed to connect to MCP servers.");
throw;
}
}

private static bool IsDevelopment()
{
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Production";
return env.Equals("Development", StringComparison.OrdinalIgnoreCase);
}

private IList<AITool>? FilterW365Tools(IList<AITool>? allTools)
{
var w365Tools = allTools?.Where(t =>
{
var name = (t as AIFunction)?.Name ?? t.ToString() ?? string.Empty;
return name.StartsWith("W365_", StringComparison.OrdinalIgnoreCase);
}).ToList();

if (w365Tools != null && w365Tools.Count > 0)
{
_logger.LogInformation("Found {ToolCount} W365 Computer Use tools", w365Tools.Count);
}
else
{
_logger.LogWarning("No W365 tools found among {TotalCount} MCP tools", allTools?.Count ?? 0);
}

return w365Tools;
}
}
Loading
Loading