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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;

Env.TraversePath().Load();

Expand Down Expand Up @@ -96,22 +95,19 @@ User Isolation Agent Sample
if (!userSessions.TryGetValue(userId, out ChatClientAgentSession? userSession))
{
userSession = await agent.CreateFoundryHostedAgentSessionAsync(
hostedSessionId: hostedSessionId);
hostedSessionId: hostedSessionId,
userIdentity: userId);
userSessions.Add(userId, userSession);
Console.WriteLine($"Created an independent conversation for '{userId}'.");
}

var runOptions = new ChatClientAgentRunOptions(
new ChatOptions().WithFoundryHostedAgentUserIdentity(userId));

Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Agent for {userId}> ");
Console.ResetColor();

await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
input,
userSession,
runOptions))
userSession))
{
Console.Write(update);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ This client demonstrates delegated user identity for a middle tier that serves s
users through one Foundry hosted session.

The sample creates one shared hosted session. Each user receives a separate Agent Framework
`AgentSession`, and every invocation sends that user's stable identifier through
`ChatOptions.WithFoundryHostedAgentUserIdentity`. Agent Framework places the value in the
`x-ms-user-identity` request header.
`AgentSession` created with that user's stable identifier. Agent Framework stores the identifier
with the session and automatically places it in the `x-ms-user-identity` request header on every
invocation that reuses the session.

## What Foundry isolates

Expand All @@ -25,6 +25,10 @@ The sample deliberately creates one `AgentSession` per user. Reusing one `AgentS
users would also reuse its conversation continuation identifier. Foundry rejects that cross user
continuation rather than exposing the first user's history.

The delegated identity is fixed when `CreateFoundryHostedAgentSessionAsync` creates the local
`AgentSession`. To serve another user, create another `AgentSession`; it may still reference the same
hosted sandbox.

## What the application must isolate

Foundry isolates the conversation history it manages. It does not automatically partition arbitrary
Expand Down
21 changes: 17 additions & 4 deletions dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ public ValueTask<AgentSession> CreateSessionAsync(string conversationId, Cancell

/// <summary>
/// Creates a local <see cref="ChatClientAgentSession"/> optionally pinned to a Foundry hosted-agent
/// session id (sandbox) and/or a server conversation id.
/// session id (sandbox), a server conversation id, and a delegated application user identity.
/// </summary>
/// <param name="hostedSessionId">
/// Optional existing hosted-agent session id to pin on the session. The id identifies a Foundry
Expand All @@ -181,8 +181,14 @@ public ValueTask<AgentSession> CreateSessionAsync(string conversationId, Cancell
/// history and hosted-agent session (sandbox) are separate Foundry concepts; see
/// <see href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents#sessions-and-conversations">Sessions and conversations</see>.
/// </param>
/// <param name="userIdentity">
/// Optional opaque application user identifier to associate with the session. When set, it is
/// stored in <see cref="AgentSession.StateBag"/> and sent as <c>x-ms-user-identity</c> on every
/// run that reuses this session. Create a separate <see cref="AgentSession"/> for each user;
/// separate sessions may share the same <paramref name="hostedSessionId"/>.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ChatClientAgentSession"/> with the optional pins applied.</returns>
/// <returns>A <see cref="ChatClientAgentSession"/> with the optional bindings applied.</returns>
/// <remarks>
/// <para>
/// The hosted-agent session itself is owned and lifecycle managed by Foundry Agent Service
Expand All @@ -199,6 +205,7 @@ public ValueTask<AgentSession> CreateSessionAsync(string conversationId, Cancell
public async Task<ChatClientAgentSession> CreateFoundryHostedAgentSessionAsync(
string? hostedSessionId = null,
string? conversationId = null,
string? userIdentity = null,
Comment thread
rogerbarreto marked this conversation as resolved.
Comment thread
rogerbarreto marked this conversation as resolved.
CancellationToken cancellationToken = default)
{
AgentSession session = conversationId is null
Expand All @@ -212,6 +219,12 @@ public async Task<ChatClientAgentSession> CreateFoundryHostedAgentSessionAsync(
typed.FoundryHostedAgentSessionId = hostedSessionId;
}

if (userIdentity is not null)
Comment thread
rogerbarreto marked this conversation as resolved.
{
// Non-null values are treated as an explicit identity binding; whitespace is rejected by Set.
typed.FoundryHostedAgentUserIdentity = userIdentity;
}

return typed;
}

Expand Down Expand Up @@ -297,11 +310,11 @@ private static AIAgent CreateResponsesChatClientAgent(
}

/// <summary>
/// Registers Foundry per-call pipeline policies and wraps the agent so request-scoped
/// Registers Foundry pipeline policies and wraps the agent so request context
/// headers/body fields reach the wire:
/// <list type="bullet">
/// <item><description><c>x-client-*</c> via <see cref="ClientHeadersAgent"/> / <see cref="ClientHeadersPolicy"/></description></item>
/// <item><description><c>x-ms-user-identity</c> and sticky <c>agent_session_id</c> via <see cref="FoundryHostedRequestAgent"/></description></item>
/// <item><description>sticky <c>x-ms-user-identity</c> and <c>agent_session_id</c> via <see cref="FoundryHostedRequestAgent"/></description></item>
/// </list>
/// Idempotent per decorator type.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI;
/// serializing with the session.
/// </para>
/// <para>
/// The delegated user identity is stored in the same state bag. A session created for one user must
/// not be reused for another user, even when both sessions share the same hosted-agent session id.
/// </para>
/// <para>
/// This is not <see cref="Extensions.AI.ChatOptions.AdditionalProperties"/>. Per-call
/// overrides use
/// <see cref="Extensions.AI.FoundryChatOptionsExtensions.WithFoundryHostedAgentSessionId(Extensions.AI.ChatOptions, string)"/>.
Expand All @@ -31,6 +35,8 @@ public static class FoundryAgentSessionExtensions
/// </summary>
public const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";

private const string FoundryHostedAgentUserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity";

extension(AgentSession session)
{
/// <summary>
Expand All @@ -50,7 +56,7 @@ public static class FoundryAgentSessionExtensions
/// </para>
/// <para>
/// Prefer creating or pinning through
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, System.Threading.CancellationToken)"/>.
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, string?, System.Threading.CancellationToken)"/>.
/// The property is populated automatically when Foundry creates a sandbox on first use.
/// See
/// <see href="https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions#sessions-versus-conversations">Manage hosted agent sessions</see>.
Expand All @@ -73,5 +79,37 @@ internal set
session.StateBag.SetValue(FoundryHostedAgentSessionIdKey, value);
}
}

/// <summary>
/// Gets the delegated application user identity associated with this Agent Framework session.
/// </summary>
/// <value>
/// The opaque application user identifier sent as <c>x-ms-user-identity</c>, or
/// <see langword="null"/> when the session has no delegated identity.
/// </value>
/// <remarks>
/// The identity is fixed when the session is created through
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, string?, System.Threading.CancellationToken)"/>.
/// Reusing this session automatically sends the same identity on every run. Create a separate
/// <see cref="AgentSession"/> for each user; separate sessions may share the same
/// <c>FoundryHostedAgentSessionId</c>.
/// </remarks>
public string? FoundryHostedAgentUserIdentity
{
get
{
_ = Throw.IfNull(session);
return session.StateBag.TryGetValue<string>(FoundryHostedAgentUserIdentityKey, out var value)
? value
: null;
}

internal set
{
_ = Throw.IfNull(session);
_ = Throw.IfNullOrWhitespace(value);
session.StateBag.SetValue(FoundryHostedAgentUserIdentityKey, value);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,15 @@ namespace Microsoft.Extensions.AI;
/// </summary>
/// <remarks>
/// <para>
/// Use these helpers to attach per-call Foundry request fields:
/// <list type="bullet">
/// <item><description><see cref="WithFoundryHostedAgentSessionId"/> sends <c>agent_session_id</c> on the Responses body.</description></item>
/// <item><description><see cref="WithFoundryHostedAgentUserIdentity"/> sends <c>x-ms-user-identity</c> on the request.</description></item>
/// </list>
/// Use <see cref="WithFoundryHostedAgentSessionId"/> to send <c>agent_session_id</c> on the
/// Responses body for a single run.
Comment thread
rogerbarreto marked this conversation as resolved.
/// </para>
/// <para>
/// Hosted-agent session ids supplied via <see cref="WithFoundryHostedAgentSessionId"/> participate in the same
/// conflict rule as <see cref="ChatOptions.ConversationId"/>: if the <see cref="AgentSession"/> already
/// holds a different hosted id in its <see cref="AgentSession.StateBag"/>, the run throws
/// <see cref="System.InvalidOperationException"/>. Prefer pinning at session creation via
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, System.Threading.CancellationToken)"/>.
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, string?, System.Threading.CancellationToken)"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
Expand All @@ -39,20 +36,14 @@ public static class FoundryChatOptionsExtensions
/// </summary>
internal const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";

/// <summary>
/// Well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry the per-call
/// user identity value.
/// </summary>
internal const string FoundryHostedAgentUserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity";

/// <summary>
/// Attaches a hosted-agent session id to the per-call <paramref name="options"/> carrier.
/// </summary>
/// <remarks>
/// <para>
/// Only valid when the run's session has no hosted id yet, or already has this same id.
/// Prefer
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, System.Threading.CancellationToken)"/>
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, string?, System.Threading.CancellationToken)"/>
/// to pin at session creation.
/// </para>
/// <para>
Expand All @@ -71,44 +62,6 @@ public static ChatOptions WithFoundryHostedAgentSessionId(this ChatOptions optio
return options;
}

/// <summary>
/// Attaches a delegated user identity value that will be sent as the
/// <c>x-ms-user-identity</c> request header.
/// </summary>
/// <param name="options">The per-call chat options to mutate.</param>
/// <param name="userIdentity">Opaque application user identifier. Must be non-empty.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <remarks>
/// <para>
/// User identity is always request-scoped. It is never stored on <see cref="AgentSession"/>.
/// </para>
/// <para>
/// Per Foundry hosted-agent isolation, a Responses chain created under one user cannot be
/// continued by another user via <c>previous_response_id</c>, even when both calls share the
/// same hosted sandbox (<c>agent_session_id</c>). See
/// <see href="https://learn.microsoft.com/azure/foundry/agents/how-to/multiplex-session-users">Multiplex multiple users in one hosted agent session</see>.
/// Reusing one <see cref="AgentSession"/> across identities typically reuses that chain, so the
/// second identity's run fails at the platform (observed as a response not-found error). Prefer
/// a distinct <see cref="AgentSession"/> per identity; those sessions may still share one hosted
/// sandbox pin via <see cref="WithFoundryHostedAgentSessionId"/> or
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(string?, string?, System.Threading.CancellationToken)"/>.
/// </para>
/// <para>
/// The value is stored in <see cref="ChatOptions.AdditionalProperties"/>. Replacing that
/// dictionary after calling this method removes the value; populate or replace the dictionary
/// first, then call this method.
/// </para>
/// </remarks>
public static ChatOptions WithFoundryHostedAgentUserIdentity(this ChatOptions options, string userIdentity)
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(userIdentity);

options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
options.AdditionalProperties[FoundryHostedAgentUserIdentityKey] = userIdentity;
return options;
}

/// <summary>Reads the per-call hosted-agent session id stamped by <see cref="WithFoundryHostedAgentSessionId"/>.</summary>
internal static string? GetFoundryHostedAgentSessionId(this ChatOptions options)
{
Expand All @@ -124,20 +77,4 @@ public static ChatOptions WithFoundryHostedAgentUserIdentity(this ChatOptions op

return raw as string;
}

/// <summary>Reads the per-call user identity stamped by <see cref="WithFoundryHostedAgentUserIdentity"/>.</summary>
internal static string? GetFoundryHostedAgentUserIdentity(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}

if (!options.AdditionalProperties.TryGetValue(FoundryHostedAgentUserIdentityKey, out var raw))
{
return null;
}

return raw as string;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
namespace Microsoft.Agents.AI.Foundry;

/// <summary>
/// Delegating agent that applies Foundry hosted-agent request context per run:
/// Delegating agent that applies Foundry hosted-agent request context:
/// resolves the sticky hosted-agent session id, injects <c>agent_session_id</c> into the
/// Responses body, stamps <c>x-ms-user-identity</c>, and writes the platform-returned session
/// id back onto the <see cref="AgentSession"/>.
/// Responses body, stamps the session's <c>x-ms-user-identity</c>, and writes the
/// platform-returned session id back onto the <see cref="AgentSession"/>.
/// </summary>
internal sealed class FoundryHostedRequestAgent : DelegatingAIAgent
{
Expand Down Expand Up @@ -95,9 +95,9 @@ The hosted-agent session id provided via ChatOptions is different from the id st
var effectiveOptions = EnsureChatOptions(options, out chatOptions);
AttachHostedSessionIdFactory(chatOptions, sessionIdBox);

// Always assign (including null) so a nested Foundry run that omits the per-call Foundry
// user identity does not inherit a parent AsyncLocal value and stamp the wrong header.
UserIdentityScope.Current = chatOptions.GetFoundryHostedAgentUserIdentity();
// Always assign (including null) so a nested Foundry run without a session identity does
// not inherit a parent AsyncLocal value and stamp the wrong header.
UserIdentityScope.Current = session?.FoundryHostedAgentUserIdentity;

return new PreparedRun(effectiveOptions, sessionIdBox);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
namespace Microsoft.Agents.AI.Foundry;

/// <summary>
/// Pipeline policy that stamps <c>x-ms-user-identity</c> from <see cref="UserIdentityScope"/>
/// onto outbound OpenAI Responses requests.
/// Pipeline policy that stamps the current session's <c>x-ms-user-identity</c> from
/// <see cref="UserIdentityScope"/> onto outbound OpenAI Responses requests.
/// </summary>
internal sealed class UserIdentityPolicy : PipelinePolicy
{
Expand Down
4 changes: 2 additions & 2 deletions dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
namespace Microsoft.Agents.AI.Foundry;

/// <summary>
/// AsyncLocal carrier for the per-call <c>x-ms-user-identity</c> value from
/// AsyncLocal carrier for the session's <c>x-ms-user-identity</c> value from
/// <see cref="FoundryHostedRequestAgent"/> to <see cref="UserIdentityPolicy"/>.
/// </summary>
internal static class UserIdentityScope
{
private static readonly AsyncLocal<string?> s_current = new();

/// <summary>Gets or sets the per-async-flow user identity value.</summary>
/// <summary>Gets or sets the user identity value for the current asynchronous flow.</summary>
public static string? Current
{
get => s_current.Value;
Expand Down
Loading
Loading