Skip to content
Merged
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
18 changes: 17 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,23 @@ Agents can receive and process notifications from Agent 365:
- **WPX Comments**: Handle Word document comments
- **Custom Notifications**: Extensible notification types

### 7. Graceful Degradation
### 7. User Identity

The A365 platform populates `Activity.From` on every incoming message with the user's basic
information — no API calls or token acquisition required:

| Field | C# | Python | TypeScript |
|---|---|---|---|
| Channel user ID | `Activity.From.Id` | `activity.from_property.id` | `activity.from.id` |
| Display name | `Activity.From.Name` | `activity.from_property.name` | `activity.from.name` |
| Azure AD Object ID | `Activity.From.AadObjectId` | `activity.from_property.aad_object_id` | `activity.from.aadObjectId` |

**Pattern applied in every sample:**
1. Log all three fields at `Information`/`info` level at message handler entry
2. Inject the display name into LLM system instructions for personalized responses
3. Use `AadObjectId` to call Microsoft Graph for extended profile data when needed

### 8. Graceful Degradation

All samples support graceful degradation when tools fail:

Expand Down
130 changes: 70 additions & 60 deletions dotnet/agent-framework/sample-agent/Agent/MyAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ namespace Agent365AgentFrameworkSampleAgent.Agent
{
public class MyAgent : AgentApplication
{
private readonly string AgentWelcomeMessage = "Hello! I can help you find information based on what I can access";
private const string AgentWelcomeMessage = "Hello! I can help you find information based on what I can access.";

private readonly string AgentInstructions = """
// Non-interpolated raw string so {{ToolName}} placeholders are preserved as literal text.
// {userName} is the only dynamic token and is injected via string.Replace in GetAgentInstructions.
private static readonly string AgentInstructionsTemplate = """
You will speak like a friendly and professional virtual assistant.

The user's name is {userName}. Use their name naturally where appropriate — for example when greeting them, confirming actions, or making responses feel personal. Do not overuse it.

For questions about yourself, you should use the one of the tools: {{mcp_graph_getMyProfile}}, {{mcp_graph_getUserProfile}}, {{mcp_graph_getMyManager}}, {{mcp_graph_getUsersManager}}.

If you are working with weather information, the following instructions apply:
Expand All @@ -38,6 +42,19 @@ You will speak like a friendly and professional virtual assistant.
Otherwise you should use the tools available to you to help answer the user's questions.
""";

private static string GetAgentInstructions(string? userName)
{
// Sanitize the display name before injecting into the system prompt to prevent prompt injection.
// Activity.From.Name is channel-provided and therefore untrusted user-controlled text.
string safe = string.IsNullOrWhiteSpace(userName) ? "unknown" : userName.Trim();
// Strip control characters (newlines, tabs, etc.) that could break prompt structure
safe = System.Text.RegularExpressions.Regex.Replace(safe, @"[\p{Cc}\p{Cf}]", " ").Trim();
// Enforce a reasonable max length
if (safe.Length > 64) safe = safe[..64].TrimEnd();
if (string.IsNullOrWhiteSpace(safe)) safe = "unknown";
return AgentInstructionsTemplate.Replace("{userName}", safe, StringComparison.Ordinal);
}

private readonly IChatClient? _chatClient = null;
private readonly IConfiguration? _configuration = null;
private readonly IExporterTokenCache<AgenticTokenStruct>? _agentTokenCache = null;
Expand Down Expand Up @@ -133,6 +150,14 @@ await AgentMetrics.InvokeObservedAgentOperation(
/// <returns></returns>
protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// Log the user identity from Activity.From — set by the A365 platform on every message.
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 the appropriate auth handler based on request type
// For agentic requests, use the agentic auth handler
// For non-agentic requests, use OBO auth handler (supports bearer token or configured auth)
Expand Down Expand Up @@ -210,18 +235,42 @@ await A365OtelWrapper.InvokeObservedAgentOperation(
AssertionHelpers.ThrowIfNull(context, nameof(context));
AssertionHelpers.ThrowIfNull(_chatClient!, nameof(_chatClient));

// Create the local tools we want to register with the agent:
var toolList = new List<AITool>();
// Acquire the access token once for this turn — used for MCP tool loading.
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. Length: {Length}", bearerToken?.Length ?? 0);
accessToken = bearerToken;
agentId = Utility.ResolveAgentIdentity(context, accessToken!);
_logger?.LogInformation("Resolved agentId: '{AgentId}'", agentId ?? "(null)");
}
else
{
_logger?.LogWarning("No auth handler or bearer token available. MCP tools will not be loaded.");
}

// Setup the local tool to be able to access the AgentSDK current context,UserAuthorization and other services can be accessed from here as well.
WeatherLookupTool weatherLookupTool = new(context, _configuration!);
if (!string.IsNullOrEmpty(accessToken) && string.IsNullOrEmpty(agentId))
{
_logger?.LogWarning("Access token was acquired but agent identity could not be resolved. MCP tools will not be loaded.");
}

// Setup the tools for the agent:
// Activity.From.Name is always available — no API call needed.
var displayName = context.Activity.From?.Name;

// Create the local tools:
var toolList = new List<AITool>();
WeatherLookupTool weatherLookupTool = new(context, _configuration!);
toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate));
toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation));
toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation));

if (toolService != null)
if (toolService != null && !string.IsNullOrEmpty(agentId))
{
try
{
Expand All @@ -236,71 +285,32 @@ await A365OtelWrapper.InvokeObservedAgentOperation(
}
else
{
// Notify the user we are loading tools
await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools...");

// Check if we have a valid auth handler or bearer token for MCP
if (!string.IsNullOrEmpty(authHandlerName))
{
// Use auth handler (agentic flow)
string? agentId = Utility.ResolveAgentIdentity(context, await UserAuthorization.GetTurnTokenAsync(context, authHandlerName));
if (!string.IsNullOrEmpty(agentId))
{
var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, authHandlerName, context).ConfigureAwait(false);
// For the bearer token (development) flow, pass the token as an override and
// use OboAuthHandlerName (or fall back to AgenticAuthHandlerName) as the handler.
var handlerForMcp = !string.IsNullOrEmpty(authHandlerName)
? authHandlerName
: OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty;
var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null;

if (a365Tools != null && a365Tools.Count > 0)
{
toolList.AddRange(a365Tools);
_agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]);
}
}
else
{
_logger?.LogWarning("Could not resolve agent identity from auth handler token.");
}
}
else if (TryGetBearerTokenForDevelopment(out var bearerToken))
{
// Use bearer token from environment (non-agentic/development flow)
_logger?.LogInformation("Using bearer token from environment for MCP tools.");
_logger?.LogInformation("Bearer token length: {Length}", bearerToken?.Length ?? 0);
string? agentId = Utility.ResolveAgentIdentity(context, bearerToken!);
_logger?.LogInformation("Resolved agentId: '{AgentId}'", agentId ?? "(null)");
if (!string.IsNullOrEmpty(agentId))
{
// Pass bearer token as the last parameter (accessToken override)
// Use OboAuthHandlerName for non-agentic requests, fall back to AgenticAuthHandlerName if not set
var handlerForBearerToken = OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty;
var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForBearerToken, context, bearerToken).ConfigureAwait(false);

if (a365Tools != null && a365Tools.Count > 0)
{
toolList.AddRange(a365Tools);
_agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]);
}
}
else
{
_logger?.LogWarning("Could not resolve agent identity from bearer token.");
}
}
else
var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false);

if (a365Tools != null && a365Tools.Count > 0)
{
_logger?.LogWarning("No auth handler or bearer token available. MCP tools will not be loaded.");
toolList.AddRange(a365Tools);
_agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]);
}
}
}
catch (Exception ex)
{
// Only allow graceful fallback in Development mode when SKIP_TOOLING_ON_ERRORS is explicitly enabled
if (ShouldSkipToolingOnErrors())
{
// Graceful fallback: Log the error but continue without MCP tools
_logger?.LogWarning(ex, "Failed to register MCP tool servers. Continuing without MCP tools (SKIP_TOOLING_ON_ERRORS=true).");
}
else
{
// In production or when SKIP_TOOLING_ON_ERRORS is not enabled, fail fast
_logger?.LogError(ex, "Failed to register MCP tool servers.");
throw;
}
Expand All @@ -314,11 +324,11 @@ await A365OtelWrapper.InvokeObservedAgentOperation(
Tools = toolList
};

// Create the chat Client passing in agent instructions and tools:
// Create the chat Client passing in agent instructions and tools:
return new ChatClientAgent(_chatClient!,
new ChatClientAgentOptions
{
Instructions = AgentInstructions,
Instructions = GetAgentInstructions(displayName),
ChatOptions = toolOptions,
ChatMessageStoreFactory = ctx =>
{
Expand Down
21 changes: 21 additions & 0 deletions dotnet/agent-framework/sample-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ For comprehensive documentation and guidance on building agents with the Microso
- OpenWeather Credentials (if using the OpenWeather Tool)
- see: https://openweathermap.org/price - You will need to create a free account to get an API key (its at the bottom of the page).

## Working with User Identity

On every incoming message, the A365 platform populates `Activity.From` with basic user information — always available with no API calls or token acquisition:

| Field | Description |
|---|---|
| `Activity.From.Id` | Channel-specific user ID (e.g., `29:1AbcXyz...` in Teams) |
| `Activity.From.Name` | Display name as known to the channel |
| `Activity.From.AadObjectId` | Azure AD Object ID — use this to call Microsoft Graph |

The sample logs these fields at the start of every turn in `OnMessageAsync` ([MyAgent.cs](Agent/MyAgent.cs)) and injects `Activity.From.Name` into the LLM system instructions for personalized responses:

```csharp
var fromAccount = turnContext.Activity.From;
_logger?.LogInformation(
"Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
fromAccount?.Name ?? "(unknown)",
fromAccount?.Id ?? "(unknown)",
fromAccount?.AadObjectId ?? "(none)");
```

## Running the Agent

To set up and test this agent, refer to the [Configure Agent Testing](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/testing?tabs=dotnet) guide for complete instructions.
Expand Down
30 changes: 28 additions & 2 deletions dotnet/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,33 @@ var a365Tools = await toolService.GetMcpToolsAsync(
toolList.AddRange(a365Tools);
```

### 6. Authentication Flow
### 6. User Identity

The A365 platform populates `Activity.From` on every incoming message. Log it at message handler entry and inject the display name into LLM system instructions:

```csharp
protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState,
CancellationToken cancellationToken)
{
var fromAccount = turnContext.Activity.From;
_logger.LogInformation(
"Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
fromAccount?.Name ?? "(unknown)",
fromAccount?.Id ?? "(unknown)",
fromAccount?.AadObjectId ?? "(none)");

var displayName = fromAccount?.Name ?? "unknown";
// Inject displayName into your LLM system prompt / agent instructions
}
```

| Field | Description |
|---|---|
| `Activity.From.Id` | Channel-specific user ID (e.g., `29:1AbcXyz...` in Teams) |
| `Activity.From.Name` | Display name as known to the channel |
| `Activity.From.AadObjectId` | Azure AD Object ID — use this to call Microsoft Graph |

### 7. Authentication Flow

```csharp
// Check for bearer token (development)
Expand All @@ -210,7 +236,7 @@ else
}
```

### 7. Observability Integration
### 8. Observability Integration

```csharp
// Configure tracing
Expand Down
11 changes: 7 additions & 4 deletions dotnet/semantic-kernel/sample-agent/Agents/Agent365Agent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,22 @@ public class Agent365Agent
private const string AgentName = "Agent365Agent";
private const string TermsAndConditionsNotAcceptedInstructions = "The user has not accepted the terms and conditions. You must ask the user to accept the terms and conditions before you can help them with any tasks. You may use the 'accept_terms_and_conditions' function to accept the terms and conditions on behalf of the user. If the user tries to perform any action before accepting the terms and conditions, you must use the 'terms_and_conditions_not_accepted' function to inform them that they must accept the terms and conditions to proceed.";
private const string TermsAndConditionsAcceptedInstructions = "You may ask follow up questions until you have enough information to answer the user's question.";
private string AgentInstructions() => $@"
private string AgentInstructions(string? userName) => $@"
You are a friendly assistant that helps office workers with their daily tasks.
The user's name is {(string.IsNullOrEmpty(userName) ? "unknown" : userName)}. Use their name naturally where appropriate.
{(MyAgent.TermsAndConditionsAccepted ? TermsAndConditionsAcceptedInstructions : TermsAndConditionsNotAcceptedInstructions)}

Respond in JSON format with the following JSON schema:

{{
""contentType"": ""'Text'"",
""content"": ""{{The content of the response in plain text}}""
}}
";

private string AgentInstructions_Streaming() => $@"
private string AgentInstructions_Streaming(string? userName) => $@"
You are a friendly assistant that helps office workers with their daily tasks.
The user's name is {(string.IsNullOrEmpty(userName) ? "unknown" : userName)}. Use their name naturally where appropriate.
{(MyAgent.TermsAndConditionsAccepted ? TermsAndConditionsAcceptedInstructions : TermsAndConditionsNotAcceptedInstructions)}

Respond in Markdown format
Expand Down Expand Up @@ -137,11 +139,12 @@ public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider servic
}

// Define the agent
var displayName = turnContext.Activity.From?.Name;
this._agent =
new()
{
Id = turnContext.Activity.Recipient.AgenticAppId ?? Guid.NewGuid().ToString(),
Instructions = turnContext.StreamingResponse.IsStreamingChannel ? AgentInstructions_Streaming() : AgentInstructions(),
Instructions = turnContext.StreamingResponse.IsStreamingChannel ? AgentInstructions_Streaming(displayName) : AgentInstructions(displayName),
Name = AgentName,
Kernel = this._kernel,
Arguments = new KernelArguments(new OpenAIPromptExecutionSettings()
Expand Down
8 changes: 8 additions & 0 deletions dotnet/semantic-kernel/sample-agent/Agents/MyAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ public MyAgent(AgentApplicationOptions options, IConfiguration configuration, Ke
/// <returns></returns>
protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// Log the user identity from Activity.From — set by the A365 platform on every message.
var fromAccount = turnContext.Activity.From;
_logger.LogInformation(
"Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
fromAccount?.Name ?? "(unknown)",
fromAccount?.Id ?? "(unknown)",
fromAccount?.AadObjectId ?? "(none)");

string ObservabilityAuthHandlerName = "";
string ToolAuthHandlerName = "";
if (turnContext.IsAgenticRequest())
Expand Down
Loading
Loading