Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting

This commit is contained in:
Roger Barreto
2026-04-11 17:45:52 +01:00
Unverified
parent a82c1ede09
commit fcc96823a4
9 changed files with 195 additions and 19 deletions
@@ -11,6 +11,10 @@ Env.TraversePath().Load();
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
// Create the agent via the AI project client using the Responses API.
@@ -23,7 +27,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential(
providing explanations, brainstorming ideas, and offering guidance.
Be concise, clear, and helpful in your responses.
""",
name: "simple-agent",
name: agentName,
description: "A simple general-purpose AI assistant");
// Host the agent as a Foundry Hosted Agent using the Responses API.
@@ -2,7 +2,6 @@
"profiles": {
"Hosted-ChatClientAgent": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -1,8 +1,7 @@
{
"profiles": {
"simple-agent-foundry": {
"HostedFoundryAgent": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -1,33 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088";
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
var endpointUri = new Uri(agentEndpoint);
var options = new AIProjectClientOptions();
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
Uri clientEndpoint = endpointUri;
if (endpointUri.Scheme == "http")
if (agentEndpoint.Scheme == "http")
{
clientEndpoint = new UriBuilder(endpointUri) { Scheme = "https" }.Uri;
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(clientEndpoint, new AzureCliCredential(), options);
var agent = aiProjectClient.AsAIAgent();
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
AgentSession session = await agent.CreateSessionAsync();
@@ -229,7 +229,8 @@ public static partial class AzureAIProjectChatClientExtensions
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentOptions);
agentOptions.ChatOptions ??= new();
Throw.IfNull(agentOptions.ChatOptions);
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
IChatClient chatClient = aiProjectClient
.GetProjectOpenAIClient()
@@ -47,8 +47,20 @@ public class AgentFrameworkResponseHandler : ResponseHandler
{
// 1. Resolve agent
var agent = this.ResolveAgent(request);
var sessionStore = this.ResolveSessionStore(request);
// 2. Create the SDK event stream builder
// 2. Load or create a new session from the interaction
var sessionConversationId = request.GetConversationId() ?? Guid.NewGuid().ToString();
var chatClientAgent = agent.GetService<ChatClientAgent>();
AgentSession? session = !string.IsNullOrEmpty(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? await chatClientAgent.CreateSessionAsync(sessionConversationId, cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
// 3. Emit lifecycle events
@@ -87,7 +99,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken),
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken),
stream,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
@@ -151,6 +163,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
finally
{
await enumerator.DisposeAsync().ConfigureAwait(false);
// Persist session after streaming completes (successful or not)
if (session is not null && !string.IsNullOrEmpty(sessionConversationId))
{
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -191,6 +209,43 @@ public class AgentFrameworkResponseHandler : ResponseHandler
throw new InvalidOperationException(errorMessage);
}
/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
/// </summary>
private AgentSessionStore ResolveSessionStore(CreateResponse request)
{
var agentName = GetAgentName(request);
if (!string.IsNullOrEmpty(agentName))
{
var sessionStore = this._serviceProvider.GetKeyedService<AgentSessionStore>(agentName);
if (sessionStore is not null)
{
return sessionStore;
}
if (this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
}
}
// Try non-keyed default
var defaultSessionStore = this._serviceProvider.GetService<AgentSessionStore>();
if (defaultSessionStore is not null)
{
return defaultSessionStore;
}
var errorMessage = string.IsNullOrEmpty(agentName)
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
throw new InvalidOperationException(errorMessage);
}
private static string? GetAgentName(CreateResponse request)
{
// Try agent.name from AgentReference
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Defines the contract for storing and retrieving agent conversation sessions.
/// </summary>
/// <remarks>
/// Implementations of this interface enable persistent storage of conversation sessions,
/// allowing conversations to be resumed across HTTP requests, application restarts,
/// or different service instances in hosted scenarios.
/// </remarks>
public abstract class AgentSessionStore
{
/// <summary>
/// Saves a serialized agent session to persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
/// <param name="session">The session to save.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a serialized agent session from persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the session, or a new session if not found.
/// </returns>
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides an in-memory implementation of <see cref="AgentSessionStore"/> for development and testing scenarios.
/// </summary>
/// <remarks>
/// <para>
/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for:
/// <list type="bullet">
/// <item><description>Single-instance development scenarios</description></item>
/// <item><description>Testing and prototyping</description></item>
/// <item><description>Scenarios where session persistence across restarts is not required</description></item>
/// </list>
/// </para>
/// <para>
/// <strong>Warning:</strong> All stored sessions will be lost when the application restarts.
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
/// such as Redis, SQL Server, or Azure Cosmos DB.
/// </para>
/// </remarks>
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
private readonly ConcurrentDictionary<string, JsonElement> _sessions = new();
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
return sessionContent switch
{
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
}
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
}
@@ -45,6 +45,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
@@ -71,14 +72,27 @@ public static class FoundryHostingExtensions
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent)
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
services.TryAddSingleton(agent);
agentSessionStore ??= new InMemoryAgentSessionStore();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
services.TryAddKeyedSingleton(agent.Name, agent);
services.TryAddKeyedSingleton(agent.Name, agentSessionStore);
}
else
{
services.TryAddSingleton(agent);
services.TryAddSingleton(agentSessionStore);
}
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}