Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
e134f5d
W365 sample agent: align with ATG remote-MCP server rename + trim log…
desmarest Apr 23, 2026
5284e66
W365 sample agent: gitignore appsettings.Production.json
desmarest Apr 23, 2026
3bc9263
Two-phase routing: skip W365 session for non-CUA messages; surface ac…
desmarest Apr 23, 2026
0a2e255
Fix ordering: send 'Got it' acknowledgment before the streaming respo…
desmarest Apr 23, 2026
aeb7b67
Only show 'Acquiring session' status on cold start
desmarest Apr 23, 2026
7240dcf
Route W365 tool-load errors through the streaming response
desmarest Apr 23, 2026
803831a
Strip CUA history when calling model without computer tool
desmarest Apr 27, 2026
06d52bc
Also strip OnTaskComplete/EndSession history pairs in non-CUA filter
desmarest Apr 27, 2026
994d7b0
Save local screenshots into per-session subfolder
desmarest Apr 27, 2026
24ef3f8
Sample agent: drop V1-leftover StartSessionAsync plumbing for V2 in-p…
desmarest Apr 29, 2026
5427ed8
Sample agent: tighten EndSession/OnTaskComplete + handle gpt-5.4 reas…
desmarest May 8, 2026
3a77944
Sample agent: add narrate() function tool for live progress updates
desmarest May 8, 2026
96fd531
Sample agent: normalize OpenAI CUA action args before W365 MCP dispatch
desmarest May 8, 2026
1a6cd42
Update W365 sample for explicit sessions
desmarest May 22, 2026
704d24e
W365 sample: explicit-session MCP path + remove OBO handler
desmarest Jun 2, 2026
fe469b4
W365 sample: remove internal CustomEndpoint model provider
desmarest Jun 2, 2026
e9ebb83
W365 sample: address PR #305 review feedback
desmarest Jun 2, 2026
4c18848
W365 sample: tighten system instructions
desmarest Jun 2, 2026
868ef1c
W365 sample: address remaining style review comments
desmarest Jun 2, 2026
3850c30
W365 sample: address second round of bot review comments
desmarest Jun 2, 2026
c410ae0
Merge branch 'main' into users/bertd/w365-computer-use-sample
desmarest Jun 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
8 changes: 8 additions & 0 deletions dotnet/w365-computer-use/sample-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
appsettings.Development.json
appsettings.Production.json
Screenshots/
a365.config.json
a365.generated.config.json
app.zip
publish/
.vscode/.env
535 changes: 535 additions & 0 deletions dotnet/w365-computer-use/sample-agent/Agent/MyAgent.cs

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions dotnet/w365-computer-use/sample-agent/AspNetExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright (c) Microsoft Corporation.
// 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 W365ComputerUseSample;

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

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

var invalidAudiences = validationOptions.Audiences
.Where(audience => !Guid.TryParse(audience, out _))
.ToList();
if (invalidAudiences.Count > 0)
{
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,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
};

options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();

options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
string authorizationHeader = context.Request.Headers.Authorization.ToString();

if (string.IsNullOrEmpty(authorizationHeader))
{
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
return Task.CompletedTask;
}

string[] parts = authorizationHeader.Split(' ');
if (parts.Length != 2 || !string.Equals(parts[0], "Bearer", StringComparison.OrdinalIgnoreCase))
{
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
return Task.CompletedTask;
}

string? issuer = null;
try
{
JwtSecurityToken token = new(parts[1]);
issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value;
}
catch (ArgumentException)
{
// Malformed / opaque token — fall back to default configuration so the JwtBearer
// handler emits a 401 instead of a 500.
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
return Task.CompletedTask;
}

if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer))
{
context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key =>
{
return new ConfigurationManager<OpenIdConnectConfiguration>(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever())
{
AutomaticRefreshInterval = openIdMetadataRefresh
};
});
}
else
{
context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key =>
{
return new ConfigurationManager<OpenIdConnectConfiguration>(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever())
{
AutomaticRefreshInterval = openIdMetadataRefresh
};
});
}
Comment thread
desmarest marked this conversation as resolved.

return Task.CompletedTask;
},
OnTokenValidated = context => Task.CompletedTask,
OnForbidden = context => Task.CompletedTask,
OnAuthenticationFailed = context => Task.CompletedTask
};
});
}

public class TokenValidationOptions
{
public IList<string>? Audiences { get; set; }
public string? TenantId { get; set; }
public IList<string>? ValidIssuers { get; set; }
public bool IsGov { get; set; } = false;
public string? AzureBotServiceOpenIdMetadataUrl { get; set; }
public string? OpenIdMetadataUrl { get; set; }
public bool AzureBotServiceTokenHandling { get; set; } = true;
public TimeSpan? OpenIdMetadataRefresh { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text;

namespace W365ComputerUseSample.ComputerUse;

/// <summary>
/// Sends CUA model requests to Azure OpenAI using an API key.
/// This is the default provider for external customers.
/// </summary>
public class AzureOpenAIModelProvider : ICuaModelProvider
{
private readonly HttpClient _httpClient;
private readonly string _url;
private readonly string _apiKey;
private readonly ILogger<AzureOpenAIModelProvider> _logger;

public string ModelName { get; }

public AzureOpenAIModelProvider(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<AzureOpenAIModelProvider> logger)
{
_httpClient = httpClientFactory.CreateClient("WebClient");
_logger = logger;
var endpoint = configuration["AIServices:AzureOpenAI:Endpoint"]
?? throw new InvalidOperationException("AIServices:AzureOpenAI:Endpoint is required.");
_apiKey = configuration["AIServices:AzureOpenAI:ApiKey"]
?? throw new InvalidOperationException("AIServices:AzureOpenAI:ApiKey is required.");
var apiVersion = configuration["AIServices:AzureOpenAI:ApiVersion"] ?? "2025-04-01-preview";

// DeploymentName = deployment-based URL; ModelName = model-based URL (model sent in body)
var deploymentName = configuration["AIServices:AzureOpenAI:DeploymentName"];
ModelName = configuration["AIServices:AzureOpenAI:ModelName"]
?? deploymentName
?? "computer-use-preview";

if (!string.IsNullOrEmpty(deploymentName))
{
_url = $"{endpoint.TrimEnd('/')}/openai/deployments/{deploymentName}/responses?api-version={apiVersion}";
}
else
{
// Model-based endpoint — model name goes in the request body, not the URL
_url = $"{endpoint.TrimEnd('/')}/openai/responses?api-version={apiVersion}";
}
Comment thread
desmarest marked this conversation as resolved.
}

public async Task<string> SendAsync(string requestBody, CancellationToken cancellationToken)
{
_logger.LogInformation("Azure OpenAI request URL: {Url}", _url);
using var req = new HttpRequestMessage(HttpMethod.Post, _url);
req.Headers.Add("api-key", _apiKey);
req.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");

var resp = await _httpClient.SendAsync(req, cancellationToken);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"Azure OpenAI returned {resp.StatusCode}: {err}");
}

return await resp.Content.ReadAsStringAsync(cancellationToken);
}
}
Loading
Loading