Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
100 changes: 84 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,58 @@ 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.HostedAgentSessionIdKey"/> 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> CreateHostedSessionAsync(
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
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 (!string.IsNullOrWhiteSpace(hostedSessionId))
{
typed.SetHostedAgentSessionId(hostedSessionId!);
}
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated

return typed;
}

/// <summary>
/// Creates a server-side conversation session that appears in the Foundry Project UI.
/// </summary>
Expand Down Expand Up @@ -240,23 +292,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 +315,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 +371,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 +404,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,58 @@
// 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="HostedAgentSessionIdKey"/>. 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.WithHostedAgentSessionId(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 HostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";

/// <summary>
/// Gets the sticky hosted-agent session id from <paramref name="session"/>, or
/// <see langword="null"/> if none is stored.
/// </summary>
/// <remarks>
/// Prefer creating/pinning via
/// <see cref="FoundryAgent.CreateHostedSessionAsync(string?, string?, System.Threading.CancellationToken)"/>.
/// This getter is for reading the id after the platform assigns one (or after an explicit pin).
/// </remarks>
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
public static string? GetHostedAgentSessionId(this AgentSession session)
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
{
_ = Throw.IfNull(session);
return session.StateBag.TryGetValue<string>(HostedAgentSessionIdKey, out var value)
? value
: null;
}

/// <summary>Sets the sticky hosted-agent session id on <paramref name="session"/>.</summary>
internal static void SetHostedAgentSessionId(this AgentSession session, string hostedSessionId)
{
_ = Throw.IfNull(session);
_ = Throw.IfNullOrWhitespace(hostedSessionId);
session.StateBag.SetValue(HostedAgentSessionIdKey, hostedSessionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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="WithHostedAgentSessionId"/> sends <c>agent_session_id</c> on the Responses body.</description></item>
/// <item><description><see cref="WithUserIdentity"/> sends <c>x-ms-user-identity</c> on the request.</description></item>
/// </list>
/// </para>
/// <para>
/// Hosted-agent session ids supplied via <see cref="WithHostedAgentSessionId"/> 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.CreateHostedSessionAsync(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 UserIdentityHeaderName = "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 HostedAgentSessionIdKey = "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 UserIdentityKey = "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>
/// Only valid when the run's session has no hosted id yet, or already has this same id.
/// Prefer <see cref="FoundryAgent.CreateHostedSessionAsync(string?, string?, System.Threading.CancellationToken)"/>
/// to pin at session creation.
/// </remarks>
public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(hostedSessionId);

options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
options.AdditionalProperties[HostedAgentSessionIdKey] = 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>
/// User identity is always request-scoped. It is never stored on <see cref="AgentSession"/>.
/// The same session (same sandbox) may be used with different identities across runs.
/// </remarks>
Comment thread
rogerbarreto marked this conversation as resolved.
public static ChatOptions WithUserIdentity(this ChatOptions options, string userIdentity)
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
Comment thread
westey-m marked this conversation as resolved.
Outdated
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(userIdentity);

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

/// <summary>Reads the per-call hosted-agent session id stamped by <see cref="WithHostedAgentSessionId"/>.</summary>
internal static string? GetHostedAgentSessionId(this ChatOptions options)
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
{
if (options.AdditionalProperties is null)
{
return null;
}

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

return raw as string;
}

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

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

return raw as string;
}
}
Loading
Loading