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
101 changes: 85 additions & 16 deletions dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ internal FoundryAgent(
/// <see cref="AIProjectClient"/> reference here.
/// </summary>
internal FoundryAgent(ChatClientAgent innerAgent)
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
: base(WireFoundryRequestContext(Throw.IfNull(innerAgent)))
{
}

Expand All @@ -162,6 +162,59 @@ internal FoundryAgent(ChatClientAgent innerAgent)
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
=> this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);

/// <summary>
/// Creates a local <see cref="ChatClientAgentSession"/> optionally pinned to a Foundry hosted-agent
/// session id (sandbox) and/or a server conversation id.
/// </summary>
/// <param name="hostedSessionId">
/// Optional existing hosted-agent session id to pin on the session. The id identifies a Foundry
/// infrastructure managed sandbox (compute and persistent <c>$HOME</c>), not Agent Framework local
/// state. See
/// <see href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents#sessions-and-conversations">Sessions and conversations</see>.
/// When set, it is stored in <see cref="AgentSession.StateBag"/> under
/// <see cref="FoundryAgentSessionExtensions.FoundryHostedAgentSessionIdKey"/> and subsequent runs that
/// reuse this session send <c>agent_session_id</c> automatically. When omitted, Foundry may create
/// a session on the first run and the returned id becomes sticky on this session.
/// </param>
/// <param name="conversationId">
/// Optional existing conversation id for server-side message history continuity. Conversation
/// 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="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ChatClientAgentSession"/> with the optional pins applied.</returns>
/// <remarks>
/// <para>
/// The hosted-agent session itself is owned and lifecycle managed by Foundry Agent Service
/// (provisioning, idle suspend, TTL). This method only builds a local Agent Framework session
/// object and optionally attaches an existing platform session id. It does not call the Foundry
/// admin API to provision a sandbox. To create a platform session first, use the agent
/// administration client and pass the resulting id as <paramref name="hostedSessionId"/>.
/// </para>
/// <para>
/// For the platform model of sessions versus conversations, see
/// <see href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents#sessions-and-conversations">Hosted agents: sessions and conversations</see>.
/// </para>
/// </remarks>
public async Task<ChatClientAgentSession> CreateFoundryHostedAgentSessionAsync(
string? hostedSessionId = null,
string? conversationId = null,
CancellationToken cancellationToken = default)
{
AgentSession session = conversationId is null
? await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await this.CreateSessionAsync(conversationId, cancellationToken).ConfigureAwait(false);

var typed = (ChatClientAgentSession)session;
if (hostedSessionId is not null)
{
// Non-null values are treated as an explicit pin attempt; whitespace is rejected by Set.
typed.FoundryHostedAgentSessionId = hostedSessionId;
}

return typed;
}

/// <summary>
/// Creates a server-side conversation session that appears in the Foundry Project UI.
/// </summary>
Expand Down Expand Up @@ -240,23 +293,21 @@ private static AIAgent CreateResponsesChatClientAgent(
chatClient = clientFactory(chatClient);
}

return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
}

/// <summary>
/// Registers <see cref="ClientHeadersPolicy"/> on the agent's underlying chat client (if it
/// exposes <see cref="OpenAIRequestPolicies"/>) and wraps the agent in a
/// <see cref="ClientHeadersAgent"/> so per-call <c>x-client-*</c> headers stamped via
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> reach
/// the wire. Idempotent: if the chain already contains a <see cref="ClientHeadersAgent"/>,
/// the original instance is returned unchanged.
/// Registers Foundry per-call pipeline policies and wraps the agent so request-scoped
/// 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>
/// </list>
/// Idempotent per decorator type.
/// </summary>
private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
private static AIAgent WireFoundryRequestContext(ChatClientAgent innerAgent)
{
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
AIAgent agent = innerAgent;
Comment thread
rogerbarreto marked this conversation as resolved.

#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
Expand All @@ -265,10 +316,28 @@ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
policies,
ClientHeadersPolicy.Instance,
PipelinePosition.PerCall);
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
UserIdentityPolicy.Instance,
PipelinePosition.PerCall);
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
HostedSessionIdCapturePolicy.Instance,
PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

return new ClientHeadersAgent(innerAgent);
if (agent.GetService<ClientHeadersAgent>() is null)
{
agent = new ClientHeadersAgent(agent);
}

if (agent.GetService<FoundryHostedRequestAgent>() is null)
{
agent = new FoundryHostedRequestAgent(agent);
Comment thread
rogerbarreto marked this conversation as resolved.
}

return agent;
}

/// <summary>
Expand Down Expand Up @@ -303,7 +372,7 @@ private static AIAgent CreateInnerAgentFromAgentEndpoint(
ChatOptions = new() { Tools = tools },
};

return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, services: services));
}

/// <summary>
Expand Down Expand Up @@ -336,7 +405,7 @@ private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
ChatOptions = new() { Tools = tools },
};

return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, services: services));
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI;

/// <summary>
/// Foundry-specific extension methods for <see cref="AgentSession"/>.
/// </summary>
/// <remarks>
/// <para>
/// The hosted-agent session id (sandbox / <c>agent_session_id</c>) is stored in
/// <see cref="AgentSession.StateBag"/> under <see cref="FoundryHostedAgentSessionIdKey"/>. That keeps
/// Foundry-specific state off the sealed <see cref="ChatClientAgentSession"/> type while still
/// serializing with the session.
/// </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)"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
Comment thread
rogerbarreto marked this conversation as resolved.
public static class FoundryAgentSessionExtensions
Comment thread
rogerbarreto marked this conversation as resolved.
{
/// <summary>
/// Well-known <see cref="AgentSessionStateBag"/> key for the sticky hosted-agent session id.
/// </summary>
public const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";

extension(AgentSession session)
{
/// <summary>
/// Gets the sticky Microsoft Foundry hosted-agent session id associated with this
/// Agent Framework session.
/// </summary>
/// <value>
/// The Foundry <c>agent_session_id</c>, or <see langword="null"/> when no hosted sandbox
/// has been pinned or captured yet.
/// </value>
/// <remarks>
/// <para>
/// This id identifies the Foundry-managed hosted-agent sandbox: its compute, persisted
/// <c>$HOME</c>, and files. It is separate from
/// <see cref="ChatClientAgentSession.ConversationId"/>, which identifies conversation
/// history.
/// </para>
/// <para>
/// Prefer creating or pinning through
/// <see cref="FoundryAgent.CreateFoundryHostedAgentSessionAsync(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>.
/// </para>
/// </remarks>
public string? FoundryHostedAgentSessionId
{
get
{
_ = Throw.IfNull(session);
return session.StateBag.TryGetValue<string>(FoundryHostedAgentSessionIdKey, out var value)
? value
: null;
}

internal set
{
_ = Throw.IfNull(session);
_ = Throw.IfNullOrWhitespace(value);
session.StateBag.SetValue(FoundryHostedAgentSessionIdKey, value);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Foundry-specific extension methods for <see cref="ChatOptions"/>.
/// </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>
/// </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)"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class FoundryChatOptionsExtensions
Comment thread
rogerbarreto marked this conversation as resolved.
{
/// <summary>HTTP header name for delegated application user identity.</summary>
public const string FoundryHostedAgentUserIdentityHeaderName = "x-ms-user-identity";

/// <summary>
/// Well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry a per-call
/// hosted-agent session id.
/// </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.
Comment thread
rogerbarreto marked this conversation as resolved.
/// </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)"/>
/// to pin at session creation.
/// </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 WithFoundryHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(hostedSessionId);

options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
options.AdditionalProperties[FoundryHostedAgentSessionIdKey] = hostedSessionId;
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)
{
if (options.AdditionalProperties is null)
{
return null;
}

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

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;
}
}
Loading
Loading